Skip to content

Commit 8cbac16

Browse files
Merge pull request #2629 from IFRCGo/feature/localunit-request-changes
Local Unit: Export feature and add validations checks
2 parents a25d90d + 9ad1dcb commit 8cbac16

12 files changed

Lines changed: 462 additions & 80 deletions

assets

Binary file not shown.
Binary file not shown.
Binary file not shown.

local_units/bulk_upload.py

Lines changed: 81 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,82 @@
2020

2121
ContextType = TypeVar("ContextType")
2222

23+
START_ROW = 4
24+
25+
26+
# NOTE(IMPORTANT): These mappings should align with templates and serializer fields.
27+
# Also make sure to update export.py if there are changes here in the headers.
28+
29+
30+
BASE_HEADER_MAP = {
31+
"Date of Update": "date_of_data",
32+
"Local Unit Name (En)": "english_branch_name",
33+
"Local Unit Name (Local)": "local_branch_name",
34+
"Visibility": "visibility",
35+
"Coverage": "level",
36+
"Sub-type": "subtype",
37+
"Focal Person (En)": "focal_person_en",
38+
"Source (En)": "source_en",
39+
"Source (Local)": "source_loc",
40+
"Focal Person (Local)": "focal_person_loc",
41+
"Address (Local)": "address_loc",
42+
"Address (En)": "address_en",
43+
"Locality (Local)": "city_loc",
44+
"Locality (En)": "city_en",
45+
"Local Unit Post Code": "postcode",
46+
"Local Unit Email": "email",
47+
"Local Unit Phone Number": "phone",
48+
"Local Unit Website": "link",
49+
"Latitude": "latitude",
50+
"Longitude": "longitude",
51+
}
52+
53+
HEALTH_HEADER_MAP = {
54+
**BASE_HEADER_MAP,
55+
**{
56+
"Focal Person Name (En)": "focal_person_en",
57+
"Focal Person Name (Local)": "focal_person_loc",
58+
"Focal Person Email": "focal_point_email",
59+
"Focal Person Phone Number": "focal_point_phone_number",
60+
"Focal Person Position": "focal_point_position",
61+
"Health Facility Type": "health_facility_type",
62+
"Other Facility Type": "other_facility_type",
63+
"Affiliation": "affiliation",
64+
"Other Affiliation": "other_affiliation",
65+
"Functionality": "functionality",
66+
"Primary Health Care Center": "primary_health_care_center",
67+
"Specialities": "speciality",
68+
"Hospital Type": "hospital_type",
69+
"Teaching Hospital": "is_teaching_hospital",
70+
"In-patient Capacity": "is_in_patient_capacity",
71+
"Isolation Rooms": "is_isolation_rooms_wards",
72+
"Number of Isolation Beds": "number_of_isolation_rooms",
73+
"Warehousing": "is_warehousing",
74+
"Cold Chain": "is_cold_chain",
75+
"Other Medical Heal": "other_medical_heal",
76+
"Maximum Bed Capacity": "maximum_capacity",
77+
"General Medical Services": "general_medical_services",
78+
"Specialized Medical Services (beyond primary level)": "specialized_medical_beyond_primary_level",
79+
"Blood Services": "blood_services",
80+
"Other Services": "other_services",
81+
"Total Number of Human Resources": "total_number_of_human_resource",
82+
"General Practitioners": "general_practitioner",
83+
"Resident Doctors": "residents_doctor",
84+
"Specialists": "specialist",
85+
"Nurses": "nurse",
86+
"Nursing Aids": "nursing_aid",
87+
"Dentists": "dentist",
88+
"Midwife": "midwife",
89+
"Pharmacists": "pharmacists",
90+
"Other Profiles": "other_profiles",
91+
"Other Training Facility": "other_training_facilities",
92+
"Professional Training Facilities": "professional_training_facilities",
93+
"Ambulance Type A": "ambulance_type_a",
94+
"Ambulance Type B": "ambulance_type_b",
95+
"Ambulance Type C": "ambulance_type_c",
96+
},
97+
}
98+
2399

24100
class BulkUploadError(Exception):
25101
"""Custom exception for bulk upload errors."""
@@ -137,7 +213,7 @@ def delete_existing_local_unit(self):
137213
def _validate_type(self, fieldnames) -> None:
138214
pass
139215

140-
def is_excel_data_empty(self, sheet, data_start_row=4):
216+
def is_excel_data_empty(self, sheet, data_start_row=START_ROW):
141217
"""Check if file is empty or not"""
142218
for row in sheet.iter_rows(values_only=True, min_row=data_start_row):
143219
if any(cell is not None for cell in row):
@@ -238,7 +314,7 @@ def _finalize_failure(self) -> None:
238314
self.bulk_upload.status = LocalUnitBulkUpload.Status.FAILED
239315
self.bulk_upload.save(update_fields=["success_count", "failed_count", "status", "error_file"])
240316

241-
logger.info(f"[BulkUpload:{self.bulk_upload.pk}] FAILED: " f"{self.success_count} succeeded, {self.failed_count} failed.")
317+
logger.info(f"[BulkUpload:{self.bulk_upload.pk}] SUMMARY: " f"{self.success_count} SUCCESS, {self.failed_count} FAILED.")
242318

243319

244320
@dataclass(frozen=True)
@@ -250,28 +326,7 @@ class LocalUnitUploadContext:
250326

251327

252328
class BaseBulkUploadLocalUnit(BaseBulkUpload[LocalUnitUploadContext]):
253-
HEADER_MAP = {
254-
"Date of Update": "date_of_data",
255-
"Local Unit Name (En)": "english_branch_name",
256-
"Local Unit Name (Local)": "local_branch_name",
257-
"Visibility": "visibility",
258-
"Coverage": "level",
259-
"Sub-type": "subtype",
260-
"Focal Person (En)": "focal_person_en",
261-
"Source (En)": "source_en",
262-
"Source (Local)": "source_loc",
263-
"Focal Person (Local)": "focal_person_loc",
264-
"Address (Local)": "address_loc",
265-
"Address (En)": "address_en",
266-
"Locality (Local)": "city_loc",
267-
"Locality (En)": "city_en",
268-
"Local Unit Post Code": "postcode",
269-
"Local Unit Email": "email",
270-
"Local Unit Phone Number": "phone",
271-
"Local Unit Website": "link",
272-
"Latitude": "latitude",
273-
"Longitude": "longitude",
274-
}
329+
HEADER_MAP = BASE_HEADER_MAP
275330

276331
def __init__(self, bulk_upload: LocalUnitBulkUpload):
277332
from local_units.serializers import LocalUnitBulkUploadDetailSerializer
@@ -315,48 +370,8 @@ def _validate_type(self, fieldnames: list[str]) -> None:
315370

316371
class BulkUploadHealthData(BaseBulkUpload[LocalUnitUploadContext]):
317372
# Local Unit headers + Health Data headers
318-
HEADER_MAP = {
319-
**BaseBulkUploadLocalUnit.HEADER_MAP,
320-
**{
321-
"Focal Person Email": "focal_point_email",
322-
"Focal Person Phone Number": "focal_point_phone_number",
323-
"Focal Person Position": "focal_point_position",
324-
"Health Facility Type": "health_facility_type",
325-
"Other Facility Type": "other_facility_type",
326-
"Affiliation": "affiliation",
327-
"Other Affiliation": "other_affiliation",
328-
"Functionality": "functionality",
329-
"Primary Health Care Center": "primary_health_care_center",
330-
"Specialities": "speciality",
331-
"Hospital Type": "hospital_type",
332-
"Teaching Hospital": "is_teaching_hospital",
333-
"In-patient Capacity": "is_in_patient_capacity",
334-
"Isolation Rooms": "is_isolation_rooms_wards",
335-
"Number of Isolation Beds": "number_of_isolation_rooms",
336-
"Warehousing": "is_warehousing",
337-
"Cold Chain": "is_cold_chain",
338-
"Maximum Bed Capacity": "maximum_capacity",
339-
"General Medical Services": "general_medical_services",
340-
"Specialized Medical Services (beyond primary level)": "specialized_medical_beyond_primary_level",
341-
"Blood Services": "blood_services",
342-
"Other Services": "other_services",
343-
"Total Number of Human Resources": "total_number_of_human_resource",
344-
"General Practitioners": "general_practitioner",
345-
"Resident Doctors": "residents_doctor",
346-
"Specialists": "specialist",
347-
"Nurses": "nurse",
348-
"Nursing Aids": "nursing_aid",
349-
"Dentists": "dentist",
350-
"Midwife": "midwife",
351-
"Pharmacists": "pharmacists",
352-
"Other Profiles": "other_profiles",
353-
"Other Training Facility": "other_training_facilities",
354-
"Professional Training Facilities": "professional_training_facilities",
355-
"Ambulance Type A": "ambulance_type_a",
356-
"Ambulance Type B": "ambulance_type_b",
357-
"Ambulance Type C": "ambulance_type_c",
358-
},
359-
}
373+
374+
HEADER_MAP = HEALTH_HEADER_MAP
360375

361376
def __init__(self, bulk_upload: LocalUnitBulkUpload):
362377
from local_units.serializers import LocalUnitBulkUploadDetailSerializer

local_units/export.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from pathlib import Path
2+
from typing import Any, Callable
3+
4+
from django.conf import settings
5+
from django.http import HttpResponse
6+
from openpyxl import load_workbook
7+
8+
from local_units.bulk_upload import BASE_HEADER_MAP, HEALTH_HEADER_MAP, START_ROW
9+
from local_units.models import HealthData, LocalUnit
10+
11+
LOCAL_UNIT_EXPORT_HANDLERS: dict[str, Callable[[LocalUnit], Any]] = {
12+
"date_of_data": lambda u: u.date_of_data.strftime("%Y-%m-%d") if u.date_of_data else "",
13+
"visibility": lambda u: u.get_visibility_display() if u.visibility else "",
14+
"level": lambda u: u.level.name if u.level else "",
15+
"latitude": lambda u: u.location.y if u.location else "",
16+
"longitude": lambda u: u.location.x if u.location else "",
17+
}
18+
19+
HEALTH_EXPORT_HANDLERS: dict[str, Callable[[HealthData], Any]] = {
20+
"affiliation": lambda h: h.affiliation.name if h.affiliation else "",
21+
"functionality": lambda h: h.functionality.name if h.functionality else "",
22+
"health_facility_type": lambda h: h.health_facility_type.name if h.health_facility_type else "",
23+
"hospital_type": lambda h: h.hospital_type.name if h.hospital_type else "",
24+
"primary_health_care_center": lambda h: h.primary_health_care_center.name if h.primary_health_care_center else "",
25+
"general_medical_services": lambda h: ", ".join(m.name for m in h.general_medical_services.all()),
26+
"specialized_medical_beyond_primary_level": lambda h: ", ".join(
27+
m.name for m in h.specialized_medical_beyond_primary_level.all()
28+
),
29+
"blood_services": lambda h: ", ".join(m.name for m in h.blood_services.all()),
30+
"professional_training_facilities": lambda h: ", ".join(m.name for m in h.professional_training_facilities.all()),
31+
"other_profiles": lambda h: ", ".join(p.position for p in h.other_profiles.all()),
32+
"other_medical_heal": lambda h: "Yes" if h.other_medical_heal else "No",
33+
"is_in_patient_capacity": lambda h: "Yes" if h.is_in_patient_capacity else "No",
34+
"is_teaching_hospital": lambda h: "Yes" if h.is_teaching_hospital else "No",
35+
"is_isolation_rooms_wards": lambda h: "Yes" if h.is_isolation_rooms_wards else "No",
36+
"is_warehousing": lambda h: "Yes" if h.is_warehousing else "No",
37+
"is_cold_chain": lambda h: "Yes" if h.is_cold_chain else "No",
38+
}
39+
40+
41+
def _resolve_value(unit: LocalUnit, field: str, is_health: bool):
42+
if field in LOCAL_UNIT_EXPORT_HANDLERS:
43+
return LOCAL_UNIT_EXPORT_HANDLERS[field](unit)
44+
45+
# First check if there's a specific handler for health fields and fall back to direct attribute access
46+
if is_health and unit.health and field in HEALTH_EXPORT_HANDLERS:
47+
return HEALTH_EXPORT_HANDLERS[field](unit.health)
48+
49+
if is_health and unit.health and hasattr(unit.health, field):
50+
return getattr(unit.health, field) or ""
51+
52+
return getattr(unit, field, "") or ""
53+
54+
55+
def export_local_units_to_excel(
56+
queryset,
57+
*,
58+
is_health: bool = False,
59+
file_name: str | None = None,
60+
):
61+
"""
62+
Exports local units to an Excel file and returns it as an HTTP response.
63+
"""
64+
65+
header_map = HEALTH_HEADER_MAP if is_health else BASE_HEADER_MAP
66+
67+
if is_health:
68+
template = "Health-Care-Bulk-Import-Template-Local-Units.xlsm"
69+
else:
70+
template = "Administrative-Bulk-Import-Template-Local-Units.xlsx"
71+
72+
template_path = Path(settings.BASE_DIR) / "go-static/files/local_units" / template
73+
74+
if not template_path.exists():
75+
raise FileNotFoundError(f"Excel template not found: {template_path}")
76+
77+
wb = load_workbook(template_path, read_only=False)
78+
ws = wb.active
79+
80+
if ws is None:
81+
raise ValueError("Worksheet could not be loaded from template.")
82+
83+
headers = [cell.value for cell in ws[2] if cell.value]
84+
start_row = START_ROW
85+
86+
for row_idx, unit in enumerate(queryset, start=start_row):
87+
for col_idx, header in enumerate(headers, start=1):
88+
field = header_map.get(str(header).strip())
89+
if not field:
90+
continue
91+
ws.cell(
92+
row=row_idx,
93+
column=col_idx,
94+
value=_resolve_value(unit, field, is_health),
95+
)
96+
97+
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
98+
response["Content-Disposition"] = f'attachment; filename="{file_name or "local_units_export.xlsx"}"'
99+
100+
wb.save(response)
101+
return response
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from django.core.management.base import BaseCommand
2+
from django.db import transaction
3+
4+
from api.models import Country, CountryType
5+
from local_units.models import ExternallyManagedLocalUnit, LocalUnit, LocalUnitType
6+
7+
8+
class Command(BaseCommand):
9+
help = "Create Externally Managed status for all local units type and mark all local units as externally managed."
10+
11+
def add_arguments(self, parser):
12+
parser.add_argument(
13+
"--dry-run",
14+
action="store_true",
15+
help="Show how many local units would be updated without making changes.",
16+
)
17+
18+
@transaction.atomic
19+
def handle(self, *args, **options):
20+
local_unit_qs = LocalUnit.objects.all()
21+
22+
if options["dry_run"]:
23+
self.stdout.write(
24+
self.style.WARNING(f"[Dry Run]: {local_unit_qs.count()} local units would be marked as externally managed.")
25+
)
26+
return
27+
28+
# Create Country level Externally Managed
29+
local_unit_types = LocalUnitType.objects.all()
30+
self.stdout.write(self.style.NOTICE("\n Creating/Updating Externally Managed local units"))
31+
countries = Country.objects.filter(
32+
is_deprecated=False,
33+
independent=True,
34+
iso3__isnull=False,
35+
record_type=CountryType.COUNTRY,
36+
)
37+
for country in countries.iterator():
38+
self.stdout.write(self.style.NOTICE(f"--> Country: {country.name}"))
39+
for local_unit_type in local_unit_types.iterator():
40+
instance, _ = ExternallyManagedLocalUnit.objects.get_or_create(
41+
country=country,
42+
local_unit_type=local_unit_type,
43+
)
44+
instance.enabled = True
45+
instance.save(update_fields=["enabled"])
46+
self.stdout.write(self.style.SUCCESS(f"\t--> Created externally managed for {local_unit_type.name}"))
47+
48+
# Update all local units to Externally managed
49+
updated_count = local_unit_qs.update(
50+
status=LocalUnit.Status.EXTERNALLY_MANAGED,
51+
)
52+
53+
self.stdout.write(self.style.SUCCESS(f"Successfully marked {updated_count} local units as externally managed."))

local_units/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@ class Status(models.IntegerChoices):
442442
email = models.EmailField(max_length=255, blank=True, null=True, verbose_name=_("Email"))
443443
link = models.URLField(max_length=255, blank=True, null=True, verbose_name=_("Social link"))
444444
location = models.PointField(srid=4326, help_text="Local Unit Location")
445+
# FIXME: Migrate this to the status instead of boolen!
445446
is_deprecated = models.BooleanField(default=False, verbose_name=_("Is deprecated?"))
446447
deprecated_reason = models.IntegerField(
447448
choices=DeprecateReason.choices, verbose_name=_("deprecated reason"), blank=True, null=True

0 commit comments

Comments
 (0)