Skip to content

Commit 3eaf69a

Browse files
committed
Productionize aggregation endpoint, see #HEA-659
1 parent da40467 commit 3eaf69a

File tree

6 files changed

+522
-248
lines changed

6 files changed

+522
-248
lines changed

apps/baseline/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1123,7 +1123,7 @@ class LivelihoodActivity(common_models.Model):
11231123
quantity_sold = models.PositiveIntegerField(blank=True, null=True, verbose_name=_("Quantity Sold/Exchanged"))
11241124
quantity_other_uses = models.PositiveIntegerField(blank=True, null=True, verbose_name=_("Quantity Other Uses"))
11251125
# Can normally be calculated / validated as `quantity_produced + quantity_purchased - quantity_sold - quantity_other_uses` # NOQA: E501
1126-
# but there are exceptions, such as MilkProduction, where there is also an amount used for ButterProduction, is this captured quantity_other_uses? # NOQA: E501
1126+
# but there are exceptions, such as MilkProduction which also stores MilkProduction.quantity_butter_production
11271127
quantity_consumed = models.PositiveIntegerField(blank=True, null=True, verbose_name=_("Quantity Consumed"))
11281128

11291129
price = models.FloatField(

apps/baseline/serializers.py

Lines changed: 80 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
from django.db.models import Sum
1+
from django.db.models import F, FloatField, Sum
22
from django.utils import translation
3-
from rest_framework import fields as rest_framework_fields
43
from rest_framework import serializers
54
from rest_framework_gis.serializers import GeoFeatureModelSerializer
65

76
from common.fields import translation_fields
8-
from metadata.models import LivelihoodStrategyType
7+
from common.serializers import AggregatingSerializer
98

109
from .models import (
1110
BaselineLivelihoodActivity,
@@ -1472,28 +1471,78 @@ def get_wealth_group_label(self, obj):
14721471
return str(obj.wealth_group)
14731472

14741473

1475-
class DictQuerySetField(rest_framework_fields.SerializerMethodField):
1476-
def __init__(self, field_name=None, **kwargs):
1477-
self.field_name = field_name
1478-
super().__init__(**kwargs)
1474+
class LivelihoodZoneBaselineReportSerializer(AggregatingSerializer):
1475+
"""
1476+
There are two ‘levels’ of filter needed on this endpoint. The standard ones which are already on the LZB endpoint
1477+
filter the LZBs that are returned (eg, population range and wealth group). Let’s call them ‘global’ filters.
1478+
Everything needs filtering by wealth group or population, if those filters are active.
14791479
1480-
def to_representation(self, obj):
1481-
return self.parent.get_field(obj, self.field_name)
1480+
The data ‘slice’ strategy type and product filters do not remove LZBs from the results by themselves; they
1481+
only exclude values from the calculated slice statistics.
14821482
1483+
If a user selects Sorghum, that filters the kcals income for our slice. The kcals income for the slice is then
1484+
divided by the kcals income on the global set for the kcals income percent.
1485+
1486+
The global filters are identical to those already on the LZB endpoint (and will always be - it is sharing the
1487+
code). These are applied to the LZB, row and slice totals.
1488+
1489+
The slice filters are:
1490+
1491+
- slice_by_product (for multiple, repeat the parameter, eg, slice_by_product=R0&slice_by_product=B01). These
1492+
match any CPC code that starts with the value. (The client needs to convert the selected product to CPC.)
1493+
1494+
- slice_by_strategy_type - you can specify multiple, and you need to pass the code not the label (which could be
1495+
translated). (These are case-insensitive but otherwise must be an exact match.)
1496+
1497+
The slice is defined by matching any of the products, AND any of the strategy types (as opposed to OR).
1498+
1499+
Translated fields, eg, name, description, are rendered in the currently selected locale if possible. (Except
1500+
Country, which has different translations following ISO.) This can be selected in the UI or set using eg,
1501+
&language=pt which overrides the UI selection.
1502+
1503+
You select the fields you want using the &fields= parameter in the usual way. If you omit the fields parameter all
1504+
fields are returned. These are currently the same field list as the normal LZB endpoint, plus the aggregations,
1505+
called slice_sum_kcals_consumed, sum_kcals_consumed, kcals_consumed_percent, plus product CPC and product common
1506+
name translated. If you omit a field, the statistics for that field will be aggregated together.
1507+
1508+
The ordering code is also shared with the normal LZB endpoint, which uses the standard
1509+
&ordering= parameter. If none are specified, the results are sorted by the aggregations descending, ie,
1510+
biggest percentage first.
1511+
1512+
The strategy type codes are:
1513+
MilkProduction
1514+
ButterProduction
1515+
MeatProduction
1516+
LivestockSale
1517+
CropProduction
1518+
FoodPurchase
1519+
PaymentInKind
1520+
ReliefGiftOther
1521+
Hunting
1522+
Fishing
1523+
WildFoodGathering
1524+
OtherCashIncome
1525+
OtherPurchase
1526+
1527+
The product hierarchy can be retrieved from the classified product endpoint /api/classifiedproduct/.
1528+
1529+
You can then filter by any of the calculated fields. To do so, prefix the field name with min_ or max_.
1530+
"""
14831531

1484-
class LivelihoodZoneBaselineReportSerializer(serializers.ModelSerializer):
14851532
class Meta:
14861533
model = LivelihoodZoneBaseline
14871534
fields = (
1488-
"id",
1489-
"name",
1490-
"description",
14911535
"source_organization",
14921536
"source_organization_name",
1493-
"livelihood_zone",
1494-
"livelihood_zone_name",
14951537
"country_pk",
14961538
"country_iso_en_name",
1539+
"livelihoodzone_pk",
1540+
"livelihood_zone",
1541+
"livelihood_zone_name",
1542+
"id",
1543+
"name",
1544+
"description",
1545+
"wealth_group_category_code",
14971546
"main_livelihood_category",
14981547
"bss",
14991548
"currency",
@@ -1503,112 +1552,33 @@ class Meta:
15031552
"valid_to_date", # to display "is latest" / "is historic" in the UI for each ref yr
15041553
"population_source",
15051554
"population_estimate",
1506-
"livelihoodzone_pk",
1507-
"livelihood_strategy_pk",
15081555
"strategy_type",
1556+
"livelihood_strategy_pk",
15091557
"livelihood_activity_pk",
1510-
"wealth_group_category_code",
1511-
"population_estimate",
1512-
"slice_sum_kcals_consumed",
1513-
"sum_kcals_consumed",
1514-
"kcals_consumed_percent",
15151558
"product_cpc",
15161559
"product_common_name",
15171560
)
15181561

1519-
# For each of these aggregates the following calculation columns are added:
1520-
# (a) Total at the LZB level (filtered by population, wealth group, etc), eg, sum_kcals_consumed.
1521-
# (b) Total for the selected product/strategy type slice, eg, slice_sum_kcals_consumed.
1522-
# (c) The percentage the slice represents of the whole, eg, kcals_consumed_percent.
1523-
# Filters are automatically created, eg, min_kcals_consumed_percent and max_kcals_consumed_percent.
1524-
# If no ordering is specified by the FilterSet, the results are ordered by percent descending in the order here.
15251562
aggregates = {
15261563
"kcals_consumed": Sum,
1564+
"income": Sum,
1565+
"expenditure": Sum,
1566+
"percentage_kcals": Sum,
1567+
"kcal_income_sum": Sum(
1568+
(
1569+
F("livelihood_strategies__livelihoodactivity__quantity_purchased")
1570+
+ F("livelihood_strategies__livelihoodactivity__quantity_produced")
1571+
)
1572+
* F("livelihood_strategies__product__kcals_per_unit"),
1573+
output_field=FloatField(),
1574+
),
15271575
}
15281576

1529-
# For each of these pairs, a URL parameter is created "slice_{field}", eg, ?slice_product=
1530-
# They can appear zero, one or multiple times in the URL, and define a sub-slice of the row-level data.
1531-
# A slice includes activities with ANY of the products, AND, ANY of the strategy types.
1532-
# For example: (product=R0 OR product=L0) AND (strategy_type=MilkProd OR strategy_type=CropProd)
15331577
slice_fields = {
15341578
"product": "livelihood_strategies__product__cpc__istartswith",
15351579
"strategy_type": "livelihood_strategies__strategy_type__iexact",
15361580
}
15371581

1538-
livelihood_zone_name = DictQuerySetField("livelihood_zone_name")
1539-
source_organization_name = DictQuerySetField("source_organization_pk")
1540-
country_pk = DictQuerySetField("country_pk")
1541-
country_iso_en_name = DictQuerySetField("country_iso_en_name")
1542-
livelihoodzone_pk = DictQuerySetField("livelihoodzone_pk")
1543-
livelihood_strategy_pk = DictQuerySetField("livelihood_strategy_pk")
1544-
livelihood_activity_pk = DictQuerySetField("livelihood_activity_pk")
1545-
wealth_group_category_code = DictQuerySetField("wealth_group_category_code")
1546-
id = DictQuerySetField("id")
1547-
name = DictQuerySetField("name")
1548-
description = DictQuerySetField("description")
1549-
source_organization = DictQuerySetField("source_organization")
1550-
livelihood_zone = DictQuerySetField("livelihood_zone")
1551-
main_livelihood_category = DictQuerySetField("main_livelihood_category")
1552-
bss = DictQuerySetField("bss")
1553-
currency = DictQuerySetField("currency")
1554-
reference_year_start_date = DictQuerySetField("reference_year_start_date")
1555-
reference_year_end_date = DictQuerySetField("reference_year_end_date")
1556-
valid_from_date = DictQuerySetField("valid_from_date")
1557-
valid_to_date = DictQuerySetField("valid_to_date")
1558-
population_source = DictQuerySetField("population_source")
1559-
population_estimate = DictQuerySetField("population_estimate")
1560-
product_cpc = DictQuerySetField("product_cpc")
1561-
product_common_name = DictQuerySetField("product_common_name")
1562-
strategy_type = DictQuerySetField("strategy_type")
1563-
1564-
slice_sum_kcals_consumed = DictQuerySetField("slice_sum_kcals_consumed")
1565-
sum_kcals_consumed = DictQuerySetField("sum_kcals_consumed")
1566-
kcals_consumed_percent = DictQuerySetField("kcals_consumed_percent")
1567-
1568-
def get_fields(self):
1569-
"""
1570-
User can specify fields= parameter to specify a field list, comma-delimited.
1571-
1572-
If the fields parameter is not passed or does not match fields, defaults to self.Meta.fields.
1573-
1574-
The aggregated fields self.aggregates are added regardless of user field selection.
1575-
"""
1576-
field_list = "request" in self.context and self.context["request"].query_params.get("fields", None)
1577-
if not field_list:
1578-
return super().get_fields()
1579-
1580-
# User-provided list of fields
1581-
field_names = set(field_list.split(","))
1582-
1583-
# Add the aggregates that are always returned
1584-
for field_name, aggregate in self.aggregates.items():
1585-
field_names |= {
1586-
field_name,
1587-
self.aggregate_field_name(field_name, aggregate),
1588-
self.slice_aggregate_field_name(field_name, aggregate),
1589-
self.slice_percent_field_name(field_name, aggregate),
1590-
}
1591-
1592-
# Add the ordering field if specified
1593-
ordering = self.context["request"].query_params.get("ordering")
1594-
if ordering:
1595-
field_names.add(ordering)
1596-
1597-
# Remove any that don't match a field as a dict
1598-
return {k: v for k, v in super().get_fields().items() if k in field_names}
1599-
1600-
def get_field(self, obj, field_name):
1601-
"""
1602-
Aggregated querysets are a list of dicts.
1603-
This is called by AggregatedQuerysetField to get the value from the row dict.
1604-
"""
1605-
db_field = self.field_to_database_path(field_name)
1606-
value = obj.get(db_field, "")
1607-
# Get the readable, translated string from the choice key.
1608-
if field_name == "strategy_type" and value:
1609-
return dict(LivelihoodStrategyType.choices).get(value, value)
1610-
return value
1611-
16121582
@staticmethod
16131583
def field_to_database_path(field_name):
16141584
language_code = translation.get_language()
@@ -1621,24 +1591,15 @@ def field_to_database_path(field_name):
16211591
"livelihood_activity_pk": "livelihood_strategies__livelihoodactivity__pk",
16221592
"wealth_group_category_code": "livelihood_strategies__livelihoodactivity__wealth_group__wealth_group_category__code", # NOQA: E501
16231593
"kcals_consumed": "livelihood_strategies__livelihoodactivity__kcals_consumed",
1594+
"income": "livelihood_strategies__livelihoodactivity__income",
1595+
"expenditure": "livelihood_strategies__livelihoodactivity__expenditure",
1596+
"percentage_kcals": "livelihood_strategies__livelihoodactivity__percentage_kcals",
16241597
"livelihood_zone_name": f"livelihood_zone__name_{language_code}",
16251598
"source_organization_pk": "source_organization__pk",
16261599
"source_organization_name": "source_organization__name",
16271600
"country_pk": "livelihood_zone__country__pk",
16281601
"country_iso_en_name": "livelihood_zone__country__iso_en_name",
1629-
"product_cpc": "livelihood_strategies__product",
1602+
"product_cpc": "livelihood_strategies__product__cpc",
16301603
"strategy_type": "livelihood_strategies__strategy_type",
16311604
"product_common_name": f"livelihood_strategies__product__common_name_{language_code}",
16321605
}.get(field_name, field_name)
1633-
1634-
@staticmethod
1635-
def aggregate_field_name(field_name, aggregate):
1636-
return f"{aggregate.name.lower()}_{field_name}" # eg, sum_kcals_consumed
1637-
1638-
@staticmethod
1639-
def slice_aggregate_field_name(field_name, aggregate):
1640-
return f"slice_{aggregate.name.lower()}_{field_name}" # eg, slice_sum_kcals_consumed
1641-
1642-
@staticmethod
1643-
def slice_percent_field_name(field_name, aggregate):
1644-
return f"{field_name}_percent" # eg, kcals_consumed_percent

0 commit comments

Comments
 (0)