Skip to content

Commit af17e84

Browse files
Merge pull request #2572 from IFRCGo/feature/api-endpoint-for-health-units
Endpoint and serializer for Health units
2 parents 514e327 + 63f1a50 commit af17e84

File tree

5 files changed

+394
-0
lines changed

5 files changed

+394
-0
lines changed

local_units/filterset.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,29 @@ class Meta:
5757
"country__iso": ["exact", "in"],
5858
"country__id": ["exact", "in"],
5959
}
60+
61+
62+
class HealthLocalUnitFilters(filters.FilterSet):
63+
# Simple filters for health-local-units endpoint
64+
region = filters.NumberFilter(field_name="country__region_id", label="Region")
65+
country = filters.NumberFilter(field_name="country_id", label="Country")
66+
iso3 = filters.CharFilter(field_name="country__iso3", lookup_expr="exact", label="ISO3")
67+
validated = filters.BooleanFilter(method="filter_validated", label="Validated")
68+
subtype = filters.CharFilter(field_name="subtype", lookup_expr="icontains", label="Subtype")
69+
70+
class Meta:
71+
model = LocalUnit
72+
fields = (
73+
"region",
74+
"country",
75+
"iso3",
76+
"validated",
77+
"subtype",
78+
)
79+
80+
def filter_validated(self, queryset, name, value):
81+
if value is True:
82+
return queryset.filter(status=LocalUnit.Status.VALIDATED)
83+
if value is False:
84+
return queryset.exclude(status=LocalUnit.Status.VALIDATED)
85+
return queryset

local_units/serializers.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,3 +945,176 @@ def validate(self, validated_data):
945945
health_instance = HealthData.objects.create(**health_data)
946946
validated_data["health"] = health_instance
947947
return validated_data
948+
949+
950+
# Public, flattened serializer for Health Local Units (Type Code = 2)
951+
class _CodeNameSerializer(serializers.Serializer):
952+
code = serializers.IntegerField()
953+
name = serializers.CharField()
954+
955+
956+
class HealthLocalUnitFlatSerializer(serializers.ModelSerializer):
957+
# LocalUnit basics
958+
id = serializers.IntegerField(read_only=True)
959+
country_id = serializers.IntegerField(source="country.id", read_only=True)
960+
country_name = serializers.CharField(source="country.name", read_only=True)
961+
country_iso3 = serializers.CharField(source="country.iso3", read_only=True)
962+
type_code = serializers.IntegerField(source="type.code", read_only=True)
963+
type_name = serializers.CharField(source="type.name", read_only=True)
964+
status_display = serializers.CharField(source="get_status_display", read_only=True)
965+
location = serializers.SerializerMethodField()
966+
967+
# HealthData flattened
968+
affiliation = serializers.SerializerMethodField()
969+
functionality = serializers.SerializerMethodField()
970+
health_facility_type = serializers.SerializerMethodField()
971+
primary_health_care_center = serializers.SerializerMethodField()
972+
hospital_type = serializers.SerializerMethodField()
973+
974+
general_medical_services = serializers.SerializerMethodField()
975+
specialized_medical_beyond_primary_level = serializers.SerializerMethodField()
976+
blood_services = serializers.SerializerMethodField()
977+
professional_training_facilities = serializers.SerializerMethodField()
978+
979+
class Meta:
980+
model = LocalUnit
981+
fields = (
982+
# LocalUnit
983+
"id",
984+
"country_id",
985+
"country_name",
986+
"country_iso3",
987+
"local_branch_name",
988+
"english_branch_name",
989+
"address_loc",
990+
"address_en",
991+
"city_loc",
992+
"city_en",
993+
"postcode",
994+
"phone",
995+
"email",
996+
"link",
997+
"focal_person_loc",
998+
"focal_person_en",
999+
"date_of_data",
1000+
"subtype",
1001+
"type_code",
1002+
"type_name",
1003+
"status",
1004+
"status_display",
1005+
"location",
1006+
# HealthData core
1007+
"affiliation",
1008+
"other_affiliation",
1009+
"functionality",
1010+
"focal_point_email",
1011+
"focal_point_phone_number",
1012+
"focal_point_position",
1013+
"health_facility_type",
1014+
"other_facility_type",
1015+
"primary_health_care_center",
1016+
"speciality",
1017+
"hospital_type",
1018+
"is_teaching_hospital",
1019+
"is_in_patient_capacity",
1020+
"is_isolation_rooms_wards",
1021+
"maximum_capacity",
1022+
"number_of_isolation_rooms",
1023+
"is_warehousing",
1024+
"is_cold_chain",
1025+
"ambulance_type_a",
1026+
"ambulance_type_b",
1027+
"ambulance_type_c",
1028+
"general_medical_services",
1029+
"specialized_medical_beyond_primary_level",
1030+
"other_services",
1031+
"blood_services",
1032+
"professional_training_facilities",
1033+
"total_number_of_human_resource",
1034+
"general_practitioner",
1035+
"specialist",
1036+
"residents_doctor",
1037+
"nurse",
1038+
"dentist",
1039+
"nursing_aid",
1040+
"midwife",
1041+
"other_medical_heal",
1042+
"other_profiles",
1043+
"feedback",
1044+
)
1045+
1046+
# NOTE: HealthData direct field mappings via source
1047+
other_affiliation = serializers.CharField(source="health.other_affiliation", read_only=True)
1048+
focal_point_email = serializers.EmailField(source="health.focal_point_email", read_only=True)
1049+
focal_point_phone_number = serializers.CharField(source="health.focal_point_phone_number", read_only=True)
1050+
focal_point_position = serializers.CharField(source="health.focal_point_position", read_only=True)
1051+
other_facility_type = serializers.CharField(source="health.other_facility_type", read_only=True)
1052+
speciality = serializers.CharField(source="health.speciality", read_only=True)
1053+
is_teaching_hospital = serializers.BooleanField(source="health.is_teaching_hospital", read_only=True)
1054+
is_in_patient_capacity = serializers.BooleanField(source="health.is_in_patient_capacity", read_only=True)
1055+
is_isolation_rooms_wards = serializers.BooleanField(source="health.is_isolation_rooms_wards", read_only=True)
1056+
maximum_capacity = serializers.IntegerField(source="health.maximum_capacity", read_only=True)
1057+
number_of_isolation_rooms = serializers.IntegerField(source="health.number_of_isolation_rooms", read_only=True)
1058+
is_warehousing = serializers.BooleanField(source="health.is_warehousing", read_only=True)
1059+
is_cold_chain = serializers.BooleanField(source="health.is_cold_chain", read_only=True)
1060+
ambulance_type_a = serializers.IntegerField(source="health.ambulance_type_a", read_only=True)
1061+
ambulance_type_b = serializers.IntegerField(source="health.ambulance_type_b", read_only=True)
1062+
ambulance_type_c = serializers.IntegerField(source="health.ambulance_type_c", read_only=True)
1063+
other_services = serializers.CharField(source="health.other_services", read_only=True)
1064+
total_number_of_human_resource = serializers.IntegerField(source="health.total_number_of_human_resource", read_only=True)
1065+
general_practitioner = serializers.IntegerField(source="health.general_practitioner", read_only=True)
1066+
specialist = serializers.IntegerField(source="health.specialist", read_only=True)
1067+
residents_doctor = serializers.IntegerField(source="health.residents_doctor", read_only=True)
1068+
nurse = serializers.IntegerField(source="health.nurse", read_only=True)
1069+
dentist = serializers.IntegerField(source="health.dentist", read_only=True)
1070+
nursing_aid = serializers.IntegerField(source="health.nursing_aid", read_only=True)
1071+
midwife = serializers.IntegerField(source="health.midwife", read_only=True)
1072+
other_medical_heal = serializers.BooleanField(source="health.other_medical_heal", read_only=True)
1073+
other_profiles = serializers.CharField(source="health.other_profiles", read_only=True)
1074+
feedback = serializers.CharField(source="health.feedback", read_only=True)
1075+
1076+
def get_location(self, unit) -> dict:
1077+
return {"lat": unit.location.y, "lng": unit.location.x}
1078+
1079+
def _code_name(self, obj):
1080+
if not obj:
1081+
return None
1082+
return {"code": obj.code, "name": obj.name}
1083+
1084+
def _code_name_list(self, qs):
1085+
return [{"code": x.code, "name": x.name} for x in qs.all()] if qs is not None else []
1086+
1087+
def get_affiliation(self, obj):
1088+
return self._code_name(getattr(obj.health, "affiliation", None) if obj.health else None)
1089+
1090+
def get_functionality(self, obj):
1091+
return self._code_name(getattr(obj.health, "functionality", None) if obj.health else None)
1092+
1093+
def get_health_facility_type(self, obj):
1094+
if not obj.health or not obj.health.health_facility_type:
1095+
return None
1096+
ft = obj.health.health_facility_type
1097+
data = {"code": ft.code, "name": ft.name}
1098+
# Attach image_url if request in context
1099+
request = self.context.get("request") if self.context else None
1100+
if request:
1101+
data["image_url"] = FacilityType.get_image_map(ft.code, request)
1102+
return data
1103+
1104+
def get_primary_health_care_center(self, obj):
1105+
return self._code_name(getattr(obj.health, "primary_health_care_center", None) if obj.health else None)
1106+
1107+
def get_hospital_type(self, obj):
1108+
return self._code_name(getattr(obj.health, "hospital_type", None) if obj.health else None)
1109+
1110+
def get_general_medical_services(self, obj):
1111+
return self._code_name_list(obj.health.general_medical_services if obj.health else None)
1112+
1113+
def get_specialized_medical_beyond_primary_level(self, obj):
1114+
return self._code_name_list(obj.health.specialized_medical_beyond_primary_level if obj.health else None)
1115+
1116+
def get_blood_services(self, obj):
1117+
return self._code_name_list(obj.health.blood_services if obj.health else None)
1118+
1119+
def get_professional_training_facilities(self, obj):
1120+
return self._code_name_list(obj.health.professional_training_facilities if obj.health else None)

local_units/test_views.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1580,3 +1580,157 @@ def test_empty_health_template_file(cls):
15801580
cls.assertIsNotNone(cls.bulk_upload.error_message)
15811581
cls.assertEqual(LocalUnit.objects.count(), 5)
15821582
cls.assertEqual(HealthData.objects.count(), 5)
1583+
1584+
1585+
class TestHealthLocalUnitsPublicList(APITestCase):
1586+
"""
1587+
Tests for the public, flattened health local units endpoint: /api/v2/health-local-units/
1588+
Only add new tests; existing code remains untouched.
1589+
"""
1590+
1591+
def setUp(self):
1592+
super().setUp()
1593+
# Regions and countries
1594+
self.region1 = RegionFactory.create(name=2, label="Asia Pacific")
1595+
self.region2 = RegionFactory.create(name=1, label="Americas")
1596+
1597+
self.country1 = CountryFactory.create(name="Nepal", iso3="NPL", region=self.region1)
1598+
self.country2 = CountryFactory.create(name="Philippines", iso3="PHL", region=self.region1)
1599+
self.country3 = CountryFactory.create(name="Brazil", iso3="BRA", region=self.region2)
1600+
1601+
# Types
1602+
self.type_health = LocalUnitType.objects.create(code=2, name="Health")
1603+
self.type_admin = LocalUnitType.objects.create(code=1, name="Administrative")
1604+
1605+
# Lookups for HealthData
1606+
self.aff = Affiliation.objects.create(code=11, name="Public")
1607+
self.func = Functionality.objects.create(code=21, name="Functional")
1608+
self.ftype = FacilityType.objects.create(code=31, name="Clinic")
1609+
self.phcc = PrimaryHCC.objects.create(code=41, name="Primary")
1610+
self.htype = HospitalType.objects.create(code=51, name="District Hospital")
1611+
1612+
# Included: public, not deprecated, type=2 with health
1613+
self.hd1 = HealthDataFactory.create(
1614+
affiliation=self.aff,
1615+
functionality=self.func,
1616+
health_facility_type=self.ftype,
1617+
primary_health_care_center=self.phcc,
1618+
hospital_type=self.htype,
1619+
)
1620+
self.lu1 = LocalUnitFactory.create(
1621+
country=self.country1,
1622+
type=self.type_health,
1623+
health=self.hd1,
1624+
visibility=VisibilityChoices.PUBLIC,
1625+
is_deprecated=False,
1626+
status=LocalUnit.Status.VALIDATED,
1627+
subtype="District Clinic A",
1628+
)
1629+
1630+
self.hd2 = HealthDataFactory.create(
1631+
affiliation=self.aff,
1632+
functionality=self.func,
1633+
health_facility_type=self.ftype,
1634+
)
1635+
self.lu2 = LocalUnitFactory.create(
1636+
country=self.country2,
1637+
type=self.type_health,
1638+
health=self.hd2,
1639+
visibility=VisibilityChoices.PUBLIC,
1640+
is_deprecated=False,
1641+
status=LocalUnit.Status.UNVALIDATED,
1642+
subtype="Mobile Clinic",
1643+
)
1644+
1645+
# Exclusions
1646+
# - private visibility
1647+
LocalUnitFactory.create(
1648+
country=self.country1,
1649+
type=self.type_health,
1650+
health=HealthDataFactory.create(affiliation=self.aff, functionality=self.func, health_facility_type=self.ftype),
1651+
visibility=VisibilityChoices.MEMBERSHIP,
1652+
is_deprecated=False,
1653+
status=LocalUnit.Status.VALIDATED,
1654+
)
1655+
# - deprecated
1656+
LocalUnitFactory.create(
1657+
country=self.country1,
1658+
type=self.type_health,
1659+
health=HealthDataFactory.create(affiliation=self.aff, functionality=self.func, health_facility_type=self.ftype),
1660+
visibility=VisibilityChoices.PUBLIC,
1661+
is_deprecated=True,
1662+
status=LocalUnit.Status.VALIDATED,
1663+
)
1664+
# - wrong type (admin)
1665+
LocalUnitFactory.create(
1666+
country=self.country1,
1667+
type=self.type_admin,
1668+
health=None,
1669+
visibility=VisibilityChoices.PUBLIC,
1670+
is_deprecated=False,
1671+
status=LocalUnit.Status.VALIDATED,
1672+
)
1673+
# - no health
1674+
LocalUnitFactory.create(
1675+
country=self.country3,
1676+
type=self.type_health,
1677+
health=None,
1678+
visibility=VisibilityChoices.PUBLIC,
1679+
is_deprecated=False,
1680+
status=LocalUnit.Status.VALIDATED,
1681+
)
1682+
1683+
def test_list_public_health_local_units(self):
1684+
resp = self.client.get("/api/v2/health-local-units/")
1685+
self.assertEqual(resp.status_code, 200)
1686+
self.assertEqual(resp.data["count"], 2)
1687+
# Check a few flattened fields exist
1688+
first = resp.data["results"][0]
1689+
self.assertIn("country_name", first)
1690+
self.assertIn("country_iso3", first)
1691+
self.assertEqual(first["type_code"], 2)
1692+
self.assertIn("location", first)
1693+
self.assertIn("affiliation", first)
1694+
self.assertIn("functionality", first)
1695+
self.assertIn("health_facility_type", first)
1696+
# health_facility_type may include image_url; assert at least name exists when present
1697+
if first["health_facility_type"] is not None:
1698+
self.assertIn("name", first["health_facility_type"])
1699+
1700+
def test_filters_region_country_iso3_validated_subtype(self):
1701+
# region -> both country1 and country2 are in region1
1702+
resp = self.client.get(f"/api/v2/health-local-units/?region={self.region1.id}")
1703+
self.assertEqual(resp.status_code, 200)
1704+
self.assertEqual(resp.data["count"], 2)
1705+
1706+
# country
1707+
resp = self.client.get(f"/api/v2/health-local-units/?country={self.country1.id}")
1708+
self.assertEqual(resp.status_code, 200)
1709+
self.assertEqual(resp.data["count"], 1)
1710+
self.assertEqual(resp.data["results"][0]["country_iso3"], "NPL")
1711+
1712+
# iso3
1713+
resp = self.client.get("/api/v2/health-local-units/?iso3=PHL")
1714+
self.assertEqual(resp.status_code, 200)
1715+
self.assertEqual(resp.data["count"], 1)
1716+
self.assertEqual(resp.data["results"][0]["country_iso3"], "PHL")
1717+
1718+
# validated true
1719+
resp = self.client.get("/api/v2/health-local-units/?validated=true")
1720+
self.assertEqual(resp.status_code, 200)
1721+
self.assertEqual(resp.data["count"], 1)
1722+
self.assertEqual(resp.data["results"][0]["status"], LocalUnit.Status.VALIDATED)
1723+
1724+
# validated false
1725+
resp = self.client.get("/api/v2/health-local-units/?validated=false")
1726+
self.assertEqual(resp.status_code, 200)
1727+
self.assertEqual(resp.data["count"], 1)
1728+
self.assertEqual(resp.data["results"][0]["status"], LocalUnit.Status.UNVALIDATED)
1729+
1730+
# subtype icontains
1731+
resp = self.client.get("/api/v2/health-local-units/?subtype=mobile")
1732+
self.assertEqual(resp.status_code, 200)
1733+
self.assertEqual(resp.data["count"], 1)
1734+
self.assertEqual(resp.data["results"][0]["subtype"].lower(), "mobile clinic".lower())
1735+
1736+
# End of relevant assertions for this test.

0 commit comments

Comments
 (0)