Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/baseline/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1099,7 +1099,7 @@ class LivelihoodActivity(common_models.Model):
quantity_sold = models.PositiveIntegerField(blank=True, null=True, verbose_name=_("Quantity Sold/Exchanged"))
quantity_other_uses = models.PositiveIntegerField(blank=True, null=True, verbose_name=_("Quantity Other Uses"))
# Can normally be calculated / validated as `quantity_produced + quantity_purchased - quantity_sold - quantity_other_uses` # NOQA: E501
# but there are exceptions, such as MilkProduction, where there is also an amount used for ButterProduction
# but there are exceptions, such as MilkProduction, where there is also an amount used for ButterProduction, is this captured quantity_other_uses? # NOQA: E501
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No there is a MilkProduction.quantity_butter_production that captures that separately. Please update this comment accordingly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do thanks

quantity_consumed = models.PositiveIntegerField(blank=True, null=True, verbose_name=_("Quantity Consumed"))

price = models.FloatField(blank=True, null=True, verbose_name=_("Price"), help_text=_("Price per unit"))
Expand All @@ -1110,7 +1110,7 @@ class LivelihoodActivity(common_models.Model):
# of external goods or services.
expenditure = models.FloatField(blank=True, null=True, help_text=_("Expenditure"))

# Can normally be calculated / validated as `quantity_consumed` * `kcals_per_unit`
# Can normally be calculated / validated as `quantity_consumed` * `livelihoodstrategy__product__kcals_per_unit`
kcals_consumed = models.PositiveIntegerField(
blank=True,
null=True,
Expand Down
176 changes: 176 additions & 0 deletions apps/baseline/serializers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from django.db.models import Sum
from django.utils import translation
from rest_framework import fields as rest_framework_fields
from rest_framework import serializers
from rest_framework_gis.serializers import GeoFeatureModelSerializer

from common.fields import translation_fields
from metadata.models import LivelihoodStrategyType

from .models import (
BaselineLivelihoodActivity,
Expand Down Expand Up @@ -1466,3 +1470,175 @@ def get_strategy_label(self, obj):

def get_wealth_group_label(self, obj):
return str(obj.wealth_group)


class DictQuerySetField(rest_framework_fields.SerializerMethodField):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs a class docstring explaining why it is required / what it does.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do thanks

def __init__(self, field_name=None, **kwargs):
self.field_name = field_name
super().__init__(**kwargs)

def to_representation(self, obj):
return self.parent.get_field(obj, self.field_name)


class LivelihoodZoneBaselineReportSerializer(serializers.ModelSerializer):
class Meta:
model = LivelihoodZoneBaseline
fields = (
"id",
"name",
"description",
"source_organization",
"source_organization_name",
"livelihood_zone",
"livelihood_zone_name",
"country_pk",
"country_iso_en_name",
"main_livelihood_category",
"bss",
"currency",
"reference_year_start_date",
"reference_year_end_date",
"valid_from_date",
"valid_to_date", # to display "is latest" / "is historic" in the UI for each ref yr
"population_source",
"population_estimate",
"livelihoodzone_pk",
"livelihood_strategy_pk",
"strategy_type",
"livelihood_activity_pk",
"wealth_group_category_code",
"population_estimate",
"slice_sum_kcals_consumed",
"sum_kcals_consumed",
"kcals_consumed_percent",
"product_cpc",
"product_common_name",
)

# For each of these aggregates the following calculation columns are added:
# (a) Total at the LZB level (filtered by population, wealth group, etc), eg, sum_kcals_consumed.
# (b) Total for the selected product/strategy type slice, eg, slice_sum_kcals_consumed.
# (c) The percentage the slice represents of the whole, eg, kcals_consumed_percent.
# Filters are automatically created, eg, min_kcals_consumed_percent and max_kcals_consumed_percent.
# If no ordering is specified by the FilterSet, the results are ordered by percent descending in the order here.
aggregates = {
"kcals_consumed": Sum,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is preventing us from adding quantity_produced, quantity_consumed, income, expenditure, etc. - i.e. any field from LivelihoodActivity?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing at all, wanted to get the overall design verified before building out loads of functionality.

I do think we want to be selective though, as the bare figures are unlikely to be useful and likely to be misleading, because, eg, kcals_consumed will not work for those LS types such as income that don't store that number - we need to convert cash to a kcals equivalent to incorporate those and produce a useful statistic. I suspect the most useful aggregates will be more use-case driven, for example composite and normalized:

        "kcal_income": Sum(
            (
                F("livelihood_strategies__livelihoodactivity__quantity_purchased")
                + F("livelihood_strategies__livelihoodactivity__quantity_produced")
            )
            * F("livelihood_strategies__product__kcals_per_unit"),
        ),

This will need a small enhancement to work, but again I wanted to get the fundamentals reviewed and prototyped in the UI before getting clever.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that based on all the charts, we will need kcals_consumed, income and expenditure for most purposes - and a way to do the "complete" graph that converts income and expenditure to kcals.

}

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

livelihood_zone_name = DictQuerySetField("livelihood_zone_name")
source_organization_name = DictQuerySetField("source_organization_pk")
country_pk = DictQuerySetField("country_pk")
country_iso_en_name = DictQuerySetField("country_iso_en_name")
livelihoodzone_pk = DictQuerySetField("livelihoodzone_pk")
livelihood_strategy_pk = DictQuerySetField("livelihood_strategy_pk")
livelihood_activity_pk = DictQuerySetField("livelihood_activity_pk")
wealth_group_category_code = DictQuerySetField("wealth_group_category_code")
id = DictQuerySetField("id")
name = DictQuerySetField("name")
description = DictQuerySetField("description")
source_organization = DictQuerySetField("source_organization")
livelihood_zone = DictQuerySetField("livelihood_zone")
main_livelihood_category = DictQuerySetField("main_livelihood_category")
bss = DictQuerySetField("bss")
currency = DictQuerySetField("currency")
reference_year_start_date = DictQuerySetField("reference_year_start_date")
reference_year_end_date = DictQuerySetField("reference_year_end_date")
valid_from_date = DictQuerySetField("valid_from_date")
valid_to_date = DictQuerySetField("valid_to_date")
population_source = DictQuerySetField("population_source")
population_estimate = DictQuerySetField("population_estimate")
product_cpc = DictQuerySetField("product_cpc")
product_common_name = DictQuerySetField("product_common_name")
strategy_type = DictQuerySetField("strategy_type")

slice_sum_kcals_consumed = DictQuerySetField("slice_sum_kcals_consumed")
sum_kcals_consumed = DictQuerySetField("sum_kcals_consumed")
kcals_consumed_percent = DictQuerySetField("kcals_consumed_percent")

def get_fields(self):
"""
User can specify fields= parameter to specify a field list, comma-delimited.

If the fields parameter is not passed or does not match fields, defaults to self.Meta.fields.

The aggregated fields self.aggregates are added regardless of user field selection.
"""
field_list = "request" in self.context and self.context["request"].query_params.get("fields", None)
if not field_list:
return super().get_fields()

# User-provided list of fields
field_names = set(field_list.split(","))

# Add the aggregates that are always returned
for field_name, aggregate in self.aggregates.items():
field_names |= {
field_name,
self.aggregate_field_name(field_name, aggregate),
self.slice_aggregate_field_name(field_name, aggregate),
self.slice_percent_field_name(field_name, aggregate),
}

# Add the ordering field if specified
ordering = self.context["request"].query_params.get("ordering")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are hard-coding the ordering parameter rather than using https://www.django-rest-framework.org/api-guide/settings/#ordering_param we need to document it. It would be better to use settings.ORDERING_PARAM

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do thanks

if ordering:
field_names.add(ordering)

# Remove any that don't match a field as a dict
return {k: v for k, v in super().get_fields().items() if k in field_names}

def get_field(self, obj, field_name):
"""
Aggregated querysets are a list of dicts.
This is called by AggregatedQuerysetField to get the value from the row dict.
"""
db_field = self.field_to_database_path(field_name)
value = obj.get(db_field, "")
# Get the readable, translated string from the choice key.
if field_name == "strategy_type" and value:
return dict(LivelihoodStrategyType.choices).get(value, value)
return value

@staticmethod
def field_to_database_path(field_name):
language_code = translation.get_language()
return {
"livelihoodzone_pk": "pk",
"name": f"name_{language_code}",
"description": f"description_{language_code}",
"valid_to_date": "valid_to_date",
"livelihood_strategy_pk": "livelihood_strategies__pk",
"livelihood_activity_pk": "livelihood_strategies__livelihoodactivity__pk",
"wealth_group_category_code": "livelihood_strategies__livelihoodactivity__wealth_group__wealth_group_category__code", # NOQA: E501
"kcals_consumed": "livelihood_strategies__livelihoodactivity__kcals_consumed",
"livelihood_zone_name": f"livelihood_zone__name_{language_code}",
"source_organization_pk": "source_organization__pk",
"source_organization_name": "source_organization__name",
"country_pk": "livelihood_zone__country__pk",
"country_iso_en_name": "livelihood_zone__country__iso_en_name",
"product_cpc": "livelihood_strategies__product",
"strategy_type": "livelihood_strategies__strategy_type",
"product_common_name": f"livelihood_strategies__product__common_name_{language_code}",
}.get(field_name, field_name)

@staticmethod
def aggregate_field_name(field_name, aggregate):
return f"{aggregate.name.lower()}_{field_name}" # eg, sum_kcals_consumed

@staticmethod
def slice_aggregate_field_name(field_name, aggregate):
return f"slice_{aggregate.name.lower()}_{field_name}" # eg, slice_sum_kcals_consumed

@staticmethod
def slice_percent_field_name(field_name, aggregate):
return f"{field_name}_percent" # eg, kcals_consumed_percent
Loading
Loading