Skip to content

Commit 831a737

Browse files
committed
Merge branch 'main' into HEA-984/Livelihood_activity_valid_instances_errors_for_income_not_rounding_error
2 parents 773e4dd + 3226735 commit 831a737

11 files changed

Lines changed: 225 additions & 27 deletions

File tree

apps/baseline/admin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,7 @@ def get_fieldsets(self, request, obj=None):
655655
"Payment",
656656
{
657657
"fields": [
658+
"payment_product",
658659
"people_per_household",
659660
"times_per_month",
660661
"months_per_year",
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Generated by Django 5.2.11 on 2026-03-19 17:20
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("baseline", "0027_alter_seasonalactivity_livelihood_zone_baseline"),
11+
("common", "0011_additionalpermissions"),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name="paymentinkind",
17+
name="payment_product",
18+
field=models.ForeignKey(
19+
blank=True,
20+
db_column="payment_product_code",
21+
help_text="Product provided as payment, typically a staple food item in exchange for labor",
22+
null=True,
23+
on_delete=django.db.models.deletion.PROTECT,
24+
related_name="payments_in_kind",
25+
to="common.classifiedproduct",
26+
verbose_name="Payment Product",
27+
),
28+
),
29+
]

apps/baseline/models.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1830,9 +1830,30 @@ class PaymentInKind(LivelihoodActivity):
18301830
Food items that contribute to nutrition by households in a Wealth Group received in exchange for labor.
18311831
18321832
Stored on the BSS 'Data' worksheet in the 'Payment In Kind' section, typically starting around Row 514.
1833+
1834+
Payments in kind contain two Product references: the `product` is the actual labor or service performed,
1835+
and the `payment_product` is the food item received as payment. The `quantity_produced` is the amount of the
1836+
`payment_product` received annually, and the `kcals_consumed` should be the `quantity_produced` multiplied
1837+
by the `payment_product.kcals_per_unit`.
18331838
"""
18341839

1835-
# Production calculation/validation is `people_per_household * times_per_month * months_per_year`
1840+
# Note that payment_product is unusual because it is normally set using a label in Column A in the BSS,
1841+
# and so will have the same value for every Livelihood Activity. It could be considered part of the
1842+
# Livelihood Strategy rather than the individual Livelihood Activity, but it is the only example of an
1843+
# attribute that is logically part of the Livelihood Strategy but only for applies to a specific
1844+
# strategy type. We have included it in the PaymentInKind LivelihoodActivity for now, because many
1845+
# Livelihood Activity subclasses have additional attributes.
1846+
payment_product = models.ForeignKey(
1847+
ClassifiedProduct,
1848+
db_column="payment_product_code",
1849+
blank=True,
1850+
null=True,
1851+
on_delete=models.PROTECT,
1852+
verbose_name=_("Payment Product"),
1853+
help_text=_("Product provided as payment, typically a staple food item in exchange for labor"),
1854+
related_name="payments_in_kind",
1855+
)
1856+
# Production calculation/validation is `payment_per_time * people_per_household * times_per_month * months_per_year`
18361857
payment_per_time = models.FloatField(
18371858
blank=True,
18381859
null=True, # Not required if people_per_household or times_per_month is null

apps/baseline/serializers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -834,12 +834,24 @@ class PaymentInKindSerializer(LivelihoodActivitySerializer):
834834
class Meta:
835835
model = PaymentInKind
836836
fields = LivelihoodActivitySerializer.Meta.fields + [
837+
"payment_product",
838+
"payment_product_common_name",
839+
"payment_product_description",
837840
"payment_per_time",
838841
"people_per_household",
839842
"times_per_month",
840843
"months_per_year",
841844
]
842845

846+
payment_product_common_name = serializers.SerializerMethodField()
847+
payment_product_description = serializers.SerializerMethodField()
848+
849+
def get_payment_product_common_name(self, obj):
850+
return obj.payment_product.common_name if obj.payment_product else None
851+
852+
def get_payment_product_description(self, obj):
853+
return obj.payment_product.description_en if obj.payment_product else None
854+
843855

844856
class ReliefGiftOtherSerializer(LivelihoodActivitySerializer):
845857
class Meta:

apps/baseline/tests/test_viewsets.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3740,6 +3740,9 @@ def test_get_record(self):
37403740
"expenditure",
37413741
"kcals_consumed",
37423742
"percentage_kcals",
3743+
"payment_product",
3744+
"payment_product_common_name",
3745+
"payment_product_description",
37433746
"payment_per_time",
37443747
"people_per_household",
37453748
"times_per_month",
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 5.2.11 on 2026-03-20 02:19
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("common", "0011_additionalpermissions"),
11+
("metadata", "0015_season_purpose"),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name="activitylabel",
17+
name="payment_product",
18+
field=models.ForeignKey(
19+
blank=True,
20+
db_column="payment_product_code",
21+
null=True,
22+
on_delete=django.db.models.deletion.RESTRICT,
23+
related_name="payment_activity_labels",
24+
to="common.classifiedproduct",
25+
verbose_name="Payment Product",
26+
),
27+
),
28+
]

apps/metadata/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,15 @@ class LivelihoodActivityType(models.TextChoices):
473473
related_name="activity_labels",
474474
verbose_name=_("Product"),
475475
)
476+
payment_product = models.ForeignKey(
477+
ClassifiedProduct,
478+
db_column="payment_product_code",
479+
null=True,
480+
blank=True,
481+
on_delete=models.RESTRICT,
482+
related_name="payment_activity_labels",
483+
verbose_name=_("Payment Product"),
484+
)
476485
unit_of_measure = models.ForeignKey(
477486
UnitOfMeasure,
478487
db_column="unit_code",

pipelines/assets/livelihood_activity.py

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,9 @@ def get_livelihood_activity_regexes() -> list:
244244
placeholder_patterns = {
245245
"label_pattern": r"[a-zà-ÿ][a-zà-ÿ1-9',/ \.\>\-\(\)]+?",
246246
"product_pattern": r"(?P<product_id>[a-zà-ÿ][a-zà-ÿ1-9',/ \.\>\-\(\)]+?)",
247-
"season_pattern": r"(?P<season>season [12]|saison [12]|[12][a-z] season||[12][a-zà-ÿ] saison|r[eé]colte principale|principale r[eé]colte|gu|deyr+?)", # NOQA: E501
247+
"payment_product_pattern": r"(?P<payment_product_id>[a-zà-ÿ][a-zà-ÿ1-9',/ \.\>\-\(\)]+?)",
248+
"labor_pattern": r"(?P<product_id>(?:labou?r|travail|main-d'œuvre|pre-harvest labou?r|labour:? pre-harvest|harvest labou?r|labour:? harvest|post-harvest labou?r|labour:? post-harvest|travail:? pre-r[eéè]colte) *[:-]? *[a-zà-ÿ][a-zà-ÿ1-9',/ \.\>\-\(\)]+?)",
249+
"season_pattern": r"(?P<season>season [12]|saison [12]|[12][a-z] season||[12][a-zà-ÿ] saison|r[eé]colte principale|principale r[eé]colte|gu|deyr+?)",
248250
"additional_identifier_pattern": r"\(?(?P<additional_identifier>rainfed|irrigated|pluviale?|irriguée|submersion libre|submersion contrôlée|flottant)\)?",
249251
"age_gender_pattern": age_gender_pattern,
250252
"unit_of_measure_pattern": r"(?P<unit_of_measure_id>[a-z]+)",
@@ -277,6 +279,7 @@ def get_livelihood_activity_regular_expression_attributes(label: str) -> dict:
277279
"strategy_type": None,
278280
"is_start": None,
279281
"product_id": None,
282+
"payment_product_id": None,
280283
"unit_of_measure_id": None,
281284
"currency_id": None,
282285
"season": None,
@@ -341,6 +344,7 @@ def get_livelihood_activity_label_map(activity_type: str) -> dict[str, dict]:
341344
"strategy_type",
342345
"is_start",
343346
"product_id",
347+
"payment_product_id",
344348
"unit_of_measure_id",
345349
"currency_id",
346350
"season",
@@ -416,6 +420,10 @@ def get_all_label_attributes(
416420
all_label_attributes = labels.apply(lambda x: get_label_attributes(x, activity_type)).fillna("")
417421
all_label_attributes = classifiedproductlookup.do_lookup(all_label_attributes, "product_id", "product_id")
418422
all_label_attributes["product_id"] = all_label_attributes["product_id"].replace(pd.NA, None)
423+
all_label_attributes = classifiedproductlookup.do_lookup(
424+
all_label_attributes, "payment_product_id", "payment_product_id"
425+
)
426+
all_label_attributes["payment_product_id"] = all_label_attributes["payment_product_id"].replace(pd.NA, None)
419427
all_label_attributes = unitofmeasurelookup.do_lookup(
420428
all_label_attributes, "unit_of_measure_id", "unit_of_measure_id"
421429
)
@@ -461,7 +469,9 @@ def get_all_label_attributes(
461469
columns = ["activity_label", "status", "strategy_type", "product_id", "season_original", "country_id"]
462470
if livelihood_zone_id:
463471
columns.append("livelihood_zone_id")
464-
raise ValueError("Unrecognized seasons in labels:\n" + unrecognized_seasons_df[columns].to_markdown())
472+
raise ValueError(
473+
f"Unrecognized seasons in {activity_type} labels:\n" + unrecognized_seasons_df[columns].to_markdown()
474+
)
465475

466476
# Make sure we keep the same index so we can match by row number
467477
all_label_attributes.index = labels.index
@@ -541,6 +551,9 @@ def livelihood_activity_label_recognition_dataframe(
541551
recognized_attributes_df["product_name"] = (
542552
recognized_attributes_df["product_id"].map(product_name_map).fillna("")
543553
)
554+
recognized_attributes_df["payment_product_name"] = (
555+
recognized_attributes_df["payment_product_id"].map(product_name_map).fillna("")
556+
)
544557

545558
# Join the recognized attributes to the label dataframe
546559
label_df["activity_type"] = activity_type
@@ -554,10 +567,15 @@ def livelihood_activity_label_recognition_dataframe(
554567
regex_attributes_df = pd.DataFrame.from_records(
555568
all_labels_df["label"].astype(str).map(get_livelihood_activity_regular_expression_attributes)
556569
)
557-
regex_attributes_df = ClassifiedProductLookup(require_match=False).do_lookup(
558-
regex_attributes_df, "product_id", "product_id"
570+
classifiedproductlookup = ClassifiedProductLookup(require_match=False)
571+
regex_attributes_df = classifiedproductlookup.do_lookup(regex_attributes_df, "product_id", "product_id")
572+
regex_attributes_df = classifiedproductlookup.do_lookup(
573+
regex_attributes_df, "payment_product_id", "payment_product_id"
559574
)
560575
regex_attributes_df["product_name"] = regex_attributes_df["product_id"].map(product_name_map).fillna("")
576+
regex_attributes_df["payment_product_name"] = (
577+
regex_attributes_df["payment_product_id"].map(product_name_map).fillna("")
578+
)
561579
all_labels_df = all_labels_df.join(
562580
regex_attributes_df,
563581
how="left",
@@ -574,6 +592,7 @@ def livelihood_activity_label_recognition_dataframe(
574592
"strategy_type",
575593
"is_start",
576594
"product_id",
595+
"payment_product_id",
577596
"unit_of_measure_id",
578597
"currency_id",
579598
"season",
@@ -583,6 +602,7 @@ def livelihood_activity_label_recognition_dataframe(
583602
)
584603
)
585604
db_labels_df["product_name"] = db_labels_df["product_id"].map(product_name_map).fillna("")
605+
db_labels_df["payment_product_name"] = db_labels_df["payment_product_id"].map(product_name_map).fillna("")
586606
all_labels_df = all_labels_df.join(
587607
db_labels_df.set_index(["label_lower", "activity_type"]),
588608
on=("label_lower", "activity_type"),
@@ -633,6 +653,9 @@ def livelihood_activity_label_recognition_dataframe(
633653
how="left",
634654
)
635655
activity_type_label_df["product_name"] = activity_type_label_df["product_id"].map(product_name_map).fillna("")
656+
activity_type_label_df["payment_product_name"] = (
657+
activity_type_label_df["payment_product_id"].map(product_name_map).fillna("")
658+
)
636659
summary_label_dfs.append(activity_type_label_df)
637660

638661
# Concatenate all the activity type dataframes back into a single dataframe and put
@@ -655,13 +678,15 @@ def livelihood_activity_label_recognition_dataframe(
655678
"strategy_type",
656679
"attribute",
657680
"product_name",
681+
"payment_product_name",
658682
"unit_of_measure_id_original",
659683
"currency_id",
660684
"season",
661685
"additional_identifier",
662686
"notes",
663687
"household_labor_provider",
664688
"product_id",
689+
"payment_product_id",
665690
"activity_label",
666691
]
667692
]
@@ -671,11 +696,14 @@ def livelihood_activity_label_recognition_dataframe(
671696
all_labels_df[
672697
[
673698
"label_lower",
699+
"activity_type",
674700
"activity_label_regex",
675701
"strategy_type_regex",
676702
"is_start_regex",
677703
"product_id_regex",
678704
"product_name_regex",
705+
"payment_product_id_regex",
706+
"payment_product_name_regex",
679707
"unit_of_measure_id_regex",
680708
"season_regex",
681709
"additional_identifier_regex",
@@ -685,6 +713,8 @@ def livelihood_activity_label_recognition_dataframe(
685713
"is_start_db",
686714
"product_id_db",
687715
"product_name_db",
716+
"payment_product_id_db",
717+
"payment_product_name_db",
688718
"unit_of_measure_id_db",
689719
"season_db",
690720
"additional_identifier_db",
@@ -693,8 +723,8 @@ def livelihood_activity_label_recognition_dataframe(
693723
]
694724
]
695725
.drop_duplicates()
696-
.set_index("label_lower"),
697-
on="label",
726+
.set_index(["label_lower", "activity_type"]),
727+
on=["label", "activity_type"],
698728
how="inner",
699729
)
700730

@@ -1570,6 +1600,33 @@ def get_instances_from_dataframe(
15701600
livelihood_strategy["attribute_rows"][attribute],
15711601
)
15721602
)
1603+
elif attribute in activity_field_names and value:
1604+
# This is an attribute for the LivelihoodActivity rather than the LivelihoodStrategy,
1605+
# so add it to the livelihood activities for this strategy.
1606+
if attribute not in livelihood_strategy["attribute_rows"]:
1607+
livelihood_strategy["attribute_rows"][attribute] = row
1608+
for livelihood_activity in livelihood_activities_for_strategy:
1609+
livelihood_activity[attribute] = value
1610+
elif livelihood_activities_for_strategy[0][attribute] != value:
1611+
errors.append(
1612+
"Found different value '%s' from row %s for existing attribute '%s' with value '%s' from row %s for activity with label '%s'"
1613+
% (
1614+
value,
1615+
row,
1616+
attribute,
1617+
livelihood_activities_for_strategy[0][attribute],
1618+
livelihood_strategy["attribute_rows"][attribute],
1619+
label,
1620+
)
1621+
)
1622+
elif (
1623+
attribute.endswith("_original")
1624+
and attribute.removesuffix("_original") in activity_field_names
1625+
and value
1626+
):
1627+
# Keep the original value of the attribute to aid trouble-shooting.
1628+
for livelihood_activity in livelihood_activities_for_strategy:
1629+
livelihood_activity[attribute] = value
15731630

15741631
# Update the LivelihoodActivity records
15751632
if any(value for value in df.loc[row, "B":].astype(str).str.strip()):

pipelines/assets/livelihood_activity_regexes.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -308,13 +308,13 @@
308308
"price"
309309
],
310310
[
311-
"paiement en {unit_of_measure_pattern} par fois \\({product_pattern}\\)",
311+
"paiement en {unit_of_measure_pattern} par fois \\({payment_product_pattern}\\)",
312312
null,
313313
false,
314314
"payment_per_time"
315315
],
316316
[
317-
"payment in {unit_of_measure_pattern} per time \\({product_pattern}\\)",
317+
"payment in {unit_of_measure_pattern} per time \\({payment_product_pattern}\\)",
318318
null,
319319
false,
320320
"payment_per_time"
@@ -505,12 +505,6 @@
505505
true,
506506
"quantity_produced"
507507
],
508-
[
509-
"{product_pattern}{separator_pattern} {additional_identifier_pattern}: {unit_of_measure_pattern} produced",
510-
null,
511-
true,
512-
"quantity_produced"
513-
],
514508
[
515509
"{product_pattern}{separator_pattern} {unit_of_measure_pattern} produced",
516510
null,
@@ -955,6 +949,12 @@
955949
true,
956950
"quantity_produced_or_purchased"
957951
],
952+
[
953+
"{labor_pattern}",
954+
null,
955+
true,
956+
"activity_notes"
957+
],
958958
[
959959
"autre{separator_pattern} {product_pattern}",
960960
null,

0 commit comments

Comments
 (0)