Skip to content

Commit 739fb2f

Browse files
committed
Hot fix PR to add expenditure and income to the aggregating API without changing the API parameter and field names for the demo, see #HEA-659
1 parent da40467 commit 739fb2f

File tree

2 files changed

+146
-133
lines changed

2 files changed

+146
-133
lines changed

apps/baseline/serializers.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1509,11 +1509,17 @@ class Meta:
15091509
"livelihood_activity_pk",
15101510
"wealth_group_category_code",
15111511
"population_estimate",
1512+
"product_cpc",
1513+
"product_common_name",
15121514
"slice_sum_kcals_consumed",
15131515
"sum_kcals_consumed",
15141516
"kcals_consumed_percent",
1515-
"product_cpc",
1516-
"product_common_name",
1517+
"sum_income",
1518+
"slice_sum_income",
1519+
"income_percent",
1520+
"sum_expenditure",
1521+
"slice_sum_expenditure",
1522+
"expenditure_percent",
15171523
)
15181524

15191525
# For each of these aggregates the following calculation columns are added:
@@ -1524,6 +1530,8 @@ class Meta:
15241530
# If no ordering is specified by the FilterSet, the results are ordered by percent descending in the order here.
15251531
aggregates = {
15261532
"kcals_consumed": Sum,
1533+
"income": Sum,
1534+
"expenditure": Sum,
15271535
}
15281536

15291537
# For each of these pairs, a URL parameter is created "slice_{field}", eg, ?slice_product=
@@ -1532,7 +1540,12 @@ class Meta:
15321540
# For example: (product=R0 OR product=L0) AND (strategy_type=MilkProd OR strategy_type=CropProd)
15331541
slice_fields = {
15341542
"product": "livelihood_strategies__product__cpc__istartswith",
1543+
# this parameter must be set to one of values (not labels) from LivelihoodStrategyType, eg, MilkProduction
15351544
"strategy_type": "livelihood_strategies__strategy_type__iexact",
1545+
# TODO: Support filter expressions on the right here, so we can slice on, for example, a
1546+
# WealthGroupCharacteristicValue where WealthGroupCharacteristic is some hard-coded value,
1547+
# eg, the slice on WGCV where WGC=PhoneOwnership, or on WGCV > 3 where WGC=HouseholdSize, eg:
1548+
# {"phone_ownership": lambda val: Q(wgcv__path=val, wgc__path__code="PhoneOwnership")}
15361549
}
15371550

15381551
livelihood_zone_name = DictQuerySetField("livelihood_zone_name")
@@ -1565,6 +1578,14 @@ class Meta:
15651578
sum_kcals_consumed = DictQuerySetField("sum_kcals_consumed")
15661579
kcals_consumed_percent = DictQuerySetField("kcals_consumed_percent")
15671580

1581+
slice_sum_income = DictQuerySetField("slice_sum_income")
1582+
sum_income = DictQuerySetField("sum_income")
1583+
income_percent = DictQuerySetField("income_percent")
1584+
1585+
slice_sum_expenditure = DictQuerySetField("slice_sum_expenditure")
1586+
sum_expenditure = DictQuerySetField("sum_expenditure")
1587+
expenditure_percent = DictQuerySetField("expenditure_percent")
1588+
15681589
def get_fields(self):
15691590
"""
15701591
User can specify fields= parameter to specify a field list, comma-delimited.
@@ -1621,12 +1642,15 @@ def field_to_database_path(field_name):
16211642
"livelihood_activity_pk": "livelihood_strategies__livelihoodactivity__pk",
16221643
"wealth_group_category_code": "livelihood_strategies__livelihoodactivity__wealth_group__wealth_group_category__code", # NOQA: E501
16231644
"kcals_consumed": "livelihood_strategies__livelihoodactivity__kcals_consumed",
1645+
"income": "livelihood_strategies__livelihoodactivity__income",
1646+
"expenditure": "livelihood_strategies__livelihoodactivity__expenditure",
1647+
"percentage_kcals": "livelihood_strategies__livelihoodactivity__percentage_kcals",
16241648
"livelihood_zone_name": f"livelihood_zone__name_{language_code}",
16251649
"source_organization_pk": "source_organization__pk",
16261650
"source_organization_name": "source_organization__name",
16271651
"country_pk": "livelihood_zone__country__pk",
16281652
"country_iso_en_name": "livelihood_zone__country__iso_en_name",
1629-
"product_cpc": "livelihood_strategies__product",
1653+
"product_cpc": "livelihood_strategies__product__cpc",
16301654
"strategy_type": "livelihood_strategies__strategy_type",
16311655
"product_common_name": f"livelihood_strategies__product__common_name_{language_code}",
16321656
}.get(field_name, field_name)

apps/baseline/viewsets.py

Lines changed: 119 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django.apps import apps
22
from django.conf import settings
33
from django.db import models
4-
from django.db.models import F, OuterRef, Q, Subquery
4+
from django.db.models import F, FloatField, Q, Subquery
55
from django.db.models.functions import Coalesce, NullIf
66
from django.utils.translation import override
77
from django_filters import rest_framework as filters
@@ -1628,6 +1628,115 @@ class CopingStrategyViewSet(BaseModelViewSet):
16281628
]
16291629

16301630

1631+
MODELS_TO_SEARCH = [
1632+
{
1633+
"app_name": "common",
1634+
"model_name": "ClassifiedProduct",
1635+
"filter": {"key": "product", "label": "Product", "category": "products"},
1636+
},
1637+
{
1638+
"app_name": "metadata",
1639+
"model_name": "LivelihoodCategory",
1640+
"filter": {"key": "main_livelihood_category", "label": "Main Livelihood Category", "category": "zone_types"},
1641+
},
1642+
{
1643+
"app_name": "baseline",
1644+
"model_name": "LivelihoodZone",
1645+
"filter": {"key": "livelihood_zone", "label": "Livelihood zone", "category": "zones"},
1646+
},
1647+
{
1648+
"app_name": "metadata",
1649+
"model_name": "WealthCharacteristic",
1650+
"filter": {"key": "wealth_characteristic", "label": "Items", "category": "items"},
1651+
},
1652+
{
1653+
"app_name": "common",
1654+
"model_name": "Country",
1655+
"filter": {"key": "country", "label": "Country", "category": "countries"},
1656+
},
1657+
]
1658+
1659+
1660+
class LivelihoodZoneBaselineFacetedSearchView(APIView):
1661+
"""
1662+
Performs a faceted search to find Livelihood Zone Baselines using a specified search term.
1663+
1664+
The search applies to multiple related models, filtering results based on the configured
1665+
criteria for each model. For each matching result, it calculates the number of unique
1666+
livelihood zones associated with the filter and includes relevant metadata in the response.
1667+
"""
1668+
1669+
renderer_classes = [JSONRenderer]
1670+
permission_classes = [AllowAny]
1671+
1672+
def get(self, request, format=None):
1673+
"""
1674+
Return a faceted set of matching filters
1675+
"""
1676+
results = {}
1677+
search_term = request.query_params.get(settings.REST_FRAMEWORK["SEARCH_PARAM"], "")
1678+
language = request.query_params.get("language", "en")
1679+
1680+
if search_term:
1681+
for model_entry in MODELS_TO_SEARCH:
1682+
app_name = model_entry["app_name"]
1683+
model_name = model_entry["model_name"]
1684+
filter, filter_label, filter_category = (
1685+
model_entry["filter"]["key"],
1686+
model_entry["filter"]["label"],
1687+
model_entry["filter"]["category"],
1688+
)
1689+
ModelClass = apps.get_model(app_name, model_name)
1690+
search_per_model = ModelClass.objects.search(search_term)
1691+
results[filter_category] = []
1692+
# for activating language
1693+
with override(language):
1694+
for search_result in search_per_model:
1695+
if model_name == "ClassifiedProduct":
1696+
unique_zones = (
1697+
LivelihoodStrategy.objects.filter(product=search_result)
1698+
.values("livelihood_zone_baseline")
1699+
.distinct()
1700+
.count()
1701+
)
1702+
value_label, value = search_result.description, search_result.pk
1703+
elif model_name == "LivelihoodCategory":
1704+
unique_zones = LivelihoodZoneBaseline.objects.filter(
1705+
main_livelihood_category=search_result
1706+
).count()
1707+
value_label, value = search_result.description, search_result.pk
1708+
elif model_name == "LivelihoodZone":
1709+
unique_zones = LivelihoodZoneBaseline.objects.filter(livelihood_zone=search_result).count()
1710+
value_label, value = search_result.name, search_result.pk
1711+
elif model_name == "WealthCharacteristic":
1712+
unique_zones = (
1713+
WealthGroupCharacteristicValue.objects.filter(wealth_characteristic=search_result)
1714+
.values("wealth_group__livelihood_zone_baseline")
1715+
.distinct()
1716+
.count()
1717+
)
1718+
value_label, value = search_result.description, search_result.pk
1719+
elif model_name == "Country":
1720+
unique_zones = (
1721+
LivelihoodZoneBaseline.objects.filter(livelihood_zone__country=search_result)
1722+
.distinct()
1723+
.count()
1724+
)
1725+
value_label, value = search_result.iso_en_name, search_result.pk
1726+
if unique_zones > 0:
1727+
results[filter_category].append(
1728+
{
1729+
"filter": filter,
1730+
"filter_label": filter_label,
1731+
"value_label": value_label,
1732+
"value": value,
1733+
"count": unique_zones,
1734+
}
1735+
)
1736+
1737+
return Response(results)
1738+
1739+
16311740
class LivelihoodZoneBaselineReportViewSet(ModelViewSet):
16321741
"""
16331742
There are two ‘levels’ of filter needed on this endpoint. The standard ones which are already on the LZB endpoint
@@ -1770,24 +1879,9 @@ def global_aggregates(self):
17701879
"""
17711880
global_aggregates = {}
17721881
for field_name, aggregate in self.serializer_class.aggregates.items():
1773-
subquery = LivelihoodZoneBaseline.objects.all()
1774-
1775-
# The FilterSet applies the global filters, such as Wealth Group Category.
1776-
# We also need to apply these to the subquery that gets the kcal totals per LZB (eg, the kcal_percent
1777-
# denominator), to restrict the 100% value by, for example, wealth group.
1778-
subquery = self.filter_queryset(subquery)
1779-
1780-
# Join to outer query
1781-
subquery = subquery.filter(pk=OuterRef("pk"))
1782-
1783-
# Annotate with the aggregate expression, eg, sum_kcals_consumed
17841882
aggregate_field_name = self.serializer_class.aggregate_field_name(field_name, aggregate)
1785-
subquery = subquery.annotate(
1786-
**{aggregate_field_name: aggregate(self.serializer_class.field_to_database_path(field_name))}
1787-
).values(aggregate_field_name)[:1]
1788-
1789-
global_aggregates[aggregate_field_name] = Subquery(subquery)
1790-
1883+
field_path = self.serializer_class.field_to_database_path(field_name)
1884+
global_aggregates[aggregate_field_name] = aggregate(field_path, default=0, output_field=FloatField())
17911885
return global_aggregates
17921886

17931887
def get_slice_aggregates(self):
@@ -1803,7 +1897,9 @@ def get_slice_aggregates(self):
18031897
# Annotate the queryset with the aggregate, eg, slice_sum_kcals_consumed, applying the slice filters.
18041898
# This is then divided by, eg, sum_kcals_consumed for the percentage of the slice.
18051899
field_path = self.serializer_class.field_to_database_path(field_name)
1806-
slice_aggregates[aggregate_field_name] = aggregate(field_path, filter=slice_filter, default=0)
1900+
slice_aggregates[aggregate_field_name] = aggregate(
1901+
field_path, filter=slice_filter, default=0, output_field=FloatField()
1902+
)
18071903
return slice_aggregates
18081904

18091905
def get_slice_filters(self):
@@ -1825,117 +1921,10 @@ def get_calculations_on_aggregates(self):
18251921
for field_name, aggregate in self.serializer_class.aggregates.items():
18261922
slice_total = F(self.serializer_class.slice_aggregate_field_name(field_name, aggregate))
18271923
overall_total = F(self.serializer_class.aggregate_field_name(field_name, aggregate))
1828-
expr = slice_total * 100 / NullIf(overall_total, 0) # Protects against divide by zero
1829-
expr = Coalesce(expr, 0) # Zero if no LivActivities found for prod/strategy slice
1924+
# Protect against divide by zero (divide by null returns null without error)
1925+
expr = slice_total * 100 / NullIf(overall_total, 0)
1926+
# Zero if no LivActivities found for prod/strategy slice, rather than null:
1927+
expr = Coalesce(expr, 0, output_field=FloatField())
18301928
slice_percent_field_name = self.serializer_class.slice_percent_field_name(field_name, aggregate)
18311929
calcs_on_aggregates[slice_percent_field_name] = expr
18321930
return calcs_on_aggregates
1833-
1834-
1835-
MODELS_TO_SEARCH = [
1836-
{
1837-
"app_name": "common",
1838-
"model_name": "ClassifiedProduct",
1839-
"filter": {"key": "product", "label": "Product", "category": "products"},
1840-
},
1841-
{
1842-
"app_name": "metadata",
1843-
"model_name": "LivelihoodCategory",
1844-
"filter": {"key": "main_livelihood_category", "label": "Main Livelihood Category", "category": "zone_types"},
1845-
},
1846-
{
1847-
"app_name": "baseline",
1848-
"model_name": "LivelihoodZone",
1849-
"filter": {"key": "livelihood_zone", "label": "Livelihood zone", "category": "zones"},
1850-
},
1851-
{
1852-
"app_name": "metadata",
1853-
"model_name": "WealthCharacteristic",
1854-
"filter": {"key": "wealth_characteristic", "label": "Items", "category": "items"},
1855-
},
1856-
{
1857-
"app_name": "common",
1858-
"model_name": "Country",
1859-
"filter": {"key": "country", "label": "Country", "category": "countries"},
1860-
},
1861-
]
1862-
1863-
1864-
class LivelihoodZoneBaselineFacetedSearchView(APIView):
1865-
"""
1866-
Performs a faceted search to find Livelihood Zone Baselines using a specified search term.
1867-
1868-
The search applies to multiple related models, filtering results based on the configured
1869-
criteria for each model. For each matching result, it calculates the number of unique
1870-
livelihood zones associated with the filter and includes relevant metadata in the response.
1871-
"""
1872-
1873-
renderer_classes = [JSONRenderer]
1874-
permission_classes = [AllowAny]
1875-
1876-
def get(self, request, format=None):
1877-
"""
1878-
Return a faceted set of matching filters
1879-
"""
1880-
results = {}
1881-
search_term = request.query_params.get(settings.REST_FRAMEWORK["SEARCH_PARAM"], "")
1882-
language = request.query_params.get("language", "en")
1883-
1884-
if search_term:
1885-
for model_entry in MODELS_TO_SEARCH:
1886-
app_name = model_entry["app_name"]
1887-
model_name = model_entry["model_name"]
1888-
filter, filter_label, filter_category = (
1889-
model_entry["filter"]["key"],
1890-
model_entry["filter"]["label"],
1891-
model_entry["filter"]["category"],
1892-
)
1893-
ModelClass = apps.get_model(app_name, model_name)
1894-
search_per_model = ModelClass.objects.search(search_term)
1895-
results[filter_category] = []
1896-
# for activating language
1897-
with override(language):
1898-
for search_result in search_per_model:
1899-
if model_name == "ClassifiedProduct":
1900-
unique_zones = (
1901-
LivelihoodStrategy.objects.filter(product=search_result)
1902-
.values("livelihood_zone_baseline")
1903-
.distinct()
1904-
.count()
1905-
)
1906-
value_label, value = search_result.description, search_result.pk
1907-
elif model_name == "LivelihoodCategory":
1908-
unique_zones = LivelihoodZoneBaseline.objects.filter(
1909-
main_livelihood_category=search_result
1910-
).count()
1911-
value_label, value = search_result.description, search_result.pk
1912-
elif model_name == "LivelihoodZone":
1913-
unique_zones = LivelihoodZoneBaseline.objects.filter(livelihood_zone=search_result).count()
1914-
value_label, value = search_result.name, search_result.pk
1915-
elif model_name == "WealthCharacteristic":
1916-
unique_zones = (
1917-
WealthGroupCharacteristicValue.objects.filter(wealth_characteristic=search_result)
1918-
.values("wealth_group__livelihood_zone_baseline")
1919-
.distinct()
1920-
.count()
1921-
)
1922-
value_label, value = search_result.description, search_result.pk
1923-
elif model_name == "Country":
1924-
unique_zones = (
1925-
LivelihoodZoneBaseline.objects.filter(livelihood_zone__country=search_result)
1926-
.distinct()
1927-
.count()
1928-
)
1929-
value_label, value = search_result.iso_en_name, search_result.pk
1930-
if unique_zones > 0:
1931-
results[filter_category].append(
1932-
{
1933-
"filter": filter,
1934-
"filter_label": filter_label,
1935-
"value_label": value_label,
1936-
"value": value,
1937-
"count": unique_zones,
1938-
}
1939-
)
1940-
1941-
return Response(results)

0 commit comments

Comments
 (0)