Skip to content

Commit 7b53114

Browse files
authored
Merge pull request #229 from American-Institutes-for-Research/HEA-898/aide_alimentaire
Hea 898/aide alimentaire
2 parents 0d4321e + f0d95e6 commit 7b53114

10 files changed

Lines changed: 258 additions & 102 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.10 on 2026-02-01 19:02
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("baseline", "0024_baselinewealthgroupcharacteristicvalue_and_more"),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name="milkproduction",
15+
name="milking_animals",
16+
field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name="Number of milking animals"),
17+
),
18+
]

apps/baseline/models.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,8 +1012,12 @@ def clean(self):
10121012
"""
10131013
if self.strategy_type in self.REQUIRES_PRODUCT and not self.product:
10141014
raise ValidationError(_("A %s Livelihood Strategy must have a Product" % self.strategy_type))
1015-
if self.strategy_type in ["MilkProduction", "ButterProduction"] and not self.season:
1015+
if self.strategy_type in self.REQUIRES_SEASON and not self.season:
10161016
raise ValidationError(_("A %s Livelihood Strategy must have a Season" % self.strategy_type))
1017+
# All strategies require either a product or an additional identifier to distinguish them
1018+
if not self.product and not self.additional_identifier:
1019+
raise ValidationError(_("A Livelihood Strategy must have either a Product or an Additional Identifier"))
1020+
10171021
super().clean()
10181022

10191023
def save(self, *args, **kwargs):
@@ -1239,6 +1243,18 @@ def validate_quantity_produced(self):
12391243
"""
12401244
pass
12411245

1246+
def validate_quantity_purchased(self):
1247+
"""
1248+
Validate the quantity_purchased.
1249+
1250+
In most LivelihoodActivity subclasses the quantity_purchased is not used
1251+
and this validation passes.
1252+
1253+
However, FoodPurchase has additional fields that allow the quantity_purchased
1254+
to be validated. This method is overwritten in that subclass.
1255+
"""
1256+
pass
1257+
12421258
def validate_quantity_consumed(self):
12431259
# Default to 0 if any of the quantities are None
12441260
quantity_produced = self.quantity_produced or 0
@@ -1347,6 +1363,7 @@ def clean(self):
13471363
self.validate_livelihood_zone_baseline()
13481364
self.validate_strategy_type()
13491365
self.validate_quantity_produced()
1366+
self.validate_quantity_purchased()
13501367
self.validate_quantity_consumed()
13511368
self.validate_income()
13521369
self.validate_expenditure()
@@ -1505,7 +1522,12 @@ class MilkType(models.TextChoices):
15051522
WHOLE = "whole", _("whole")
15061523

15071524
# Production calculation /validation is `lactation days * daily_production`
1508-
milking_animals = models.PositiveSmallIntegerField(verbose_name=_("Number of milking animals"))
1525+
# Although logically we can't have a MilkProduction without milking animals,
1526+
# the BSS may contain records with 0 production and a blank milking_animals
1527+
# field, particularly in Season 2 in a Baseline, or in a Response scenario
1528+
milking_animals = models.PositiveSmallIntegerField(
1529+
blank=True, null=True, verbose_name=_("Number of milking animals")
1530+
)
15091531
lactation_days = models.PositiveSmallIntegerField(
15101532
blank=True, null=True, verbose_name=_("Average number of days of lactation")
15111533
)
@@ -1551,9 +1573,13 @@ def validate_quantity_produced(self):
15511573

15521574
def clean(self):
15531575
super().clean()
1554-
if self.milking_animals and not self.lactation_days:
1576+
# Note that we check that lactation_days and daily_production are not None
1577+
# because 0 is a valid value for both fields. It is possible to have a
1578+
# non-zero milking_animals but zero lactation_days or daily_production,
1579+
# particularly in Season 2 in a Baseline, or in a Response scenario.
1580+
if self.milking_animals and self.lactation_days is None:
15551581
raise ValidationError(_("Lactation days must be provided if there are milking animals"))
1556-
if self.milking_animals and not self.daily_production:
1582+
if self.milking_animals and self.daily_production is None:
15571583
raise ValidationError(_("Daily production must be provided if there are milking animals"))
15581584

15591585
class Meta:
@@ -1618,7 +1644,7 @@ class MeatProduction(LivelihoodActivity):
16181644
carcass_weight = models.FloatField(verbose_name=_("Carcass weight per animal"))
16191645

16201646
def validate_quantity_produced(self):
1621-
if self.quantity_produced != self.animals_slaughtered * self.carcass_weight:
1647+
if self.quantity_produced and self.quantity_produced != self.animals_slaughtered * self.carcass_weight:
16221648
raise ValidationError(
16231649
_("Quantity Produced for a Meat Production must be animals slaughtered multiplied by carcass weight")
16241650
)
@@ -1691,20 +1717,28 @@ class FoodPurchase(LivelihoodActivity):
16911717
help_text=_("Number of times in a year that the purchase is made"),
16921718
)
16931719

1694-
def validate_quantity_produced(self):
1720+
def validate_quantity_purchased(self):
16951721
if (
1696-
self.quantity_produced is not None
1722+
self.quantity_purchased is not None
16971723
and self.unit_multiple is not None
16981724
and self.times_per_month is not None
16991725
and self.months_per_year is not None
17001726
):
1701-
if self.quantity_produced != self.unit_multiple * self.times_per_month * self.months_per_year:
1727+
if self.quantity_purchased != self.unit_multiple * self.times_per_month * self.months_per_year:
17021728
raise ValidationError(
17031729
_(
1704-
"Quantity produced for a Food Purchase must be purchase amount * purchases per month * months per year" # NOQA: E501
1730+
"Quantity purchased for a Food Purchase must be purchase amount * purchases per month * months per year" # NOQA: E501
17051731
)
17061732
)
17071733

1734+
def validate_expenditure(self):
1735+
quantity_purchased = self.quantity_purchased or 0
1736+
price = self.price or 0
1737+
expenditure = self.expenditure or 0
1738+
1739+
if self.expenditure and expenditure != quantity_purchased * price:
1740+
raise ValidationError(_("Expenditure for a Food Purchase must be quantity purchased multiplied by price"))
1741+
17081742
class Meta:
17091743
verbose_name = LivelihoodStrategyType.FOOD_PURCHASE.label
17101744
verbose_name_plural = _("Food Purchases")

apps/baseline/tests/test_models.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -135,33 +135,33 @@ def setUpTestData(cls):
135135
unit_multiple=None,
136136
times_per_month=None,
137137
months_per_year=None,
138-
quantity_produced=None,
138+
quantity_purchased=None,
139139
)
140140
cls.foodpurchase2 = FoodPurchase(
141141
unit_multiple=2,
142142
times_per_month=5,
143143
months_per_year=12,
144-
quantity_produced=120,
144+
quantity_purchased=120,
145145
)
146146
# Incorrect: 2 * 5 * 12 = 120
147147
cls.foodpurchase3 = FoodPurchase(
148148
unit_multiple=2,
149149
times_per_month=5,
150150
months_per_year=12,
151-
quantity_produced=100,
151+
quantity_purchased=100,
152152
)
153153

154-
def test_validate_quantity_produced(self):
154+
def test_validate_quantity_purchased(self):
155155
"""
156-
Test validate_quantity_produced method
156+
Test validate_quantity_purchased method
157157
"""
158158
# Missing data should not raise ValidationError
159-
self.foodpurchase1.validate_quantity_produced()
159+
self.foodpurchase1.validate_quantity_purchased()
160160
# Expected consistant values, should not raise
161-
self.foodpurchase2.validate_quantity_produced()
161+
self.foodpurchase2.validate_quantity_purchased()
162162
# Incorrect: 2 * 5 * 12 = 120
163163
with conditional_logging():
164-
self.assertRaises(ValidationError, self.foodpurchase3.validate_quantity_produced)
164+
self.assertRaises(ValidationError, self.foodpurchase3.validate_quantity_purchased)
165165

166166

167167
class PaymentInKindTestCase(TestCase):

apps/common/lookups.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,14 @@ class Lookup(ABC):
7575
# where an Excel date or other epoch-based date format might accidentally match a primary key
7676
match_pk: bool = True
7777

78-
# Queryset Filters
78+
# Queryset Filters and Excludes
7979
filters: dict
80+
excludes: dict
8081

8182
# Related models to also instantiate
8283
related_models = []
8384

84-
def __init__(self, filters: dict = None, require_match=None):
85+
def __init__(self, filters: dict = None, excludes: dict = None, require_match=None):
8586
# Make sure we don't have any fields in multiple categories
8687
assert len(set((*self.id_fields, *self.parent_fields, *self.lookup_fields))) == len(self.id_fields) + len(
8788
self.parent_fields
@@ -90,11 +91,17 @@ def __init__(self, filters: dict = None, require_match=None):
9091
self.composite_key = len(self.id_fields) > 1
9192

9293
self.filters = filters or dict()
94+
self.excludes = excludes or dict()
9395

9496
# Override the require_match if necessary
9597
if require_match is not None:
9698
self.require_match = require_match
9799

100+
# Create instance level caches for key methods
101+
self.get_lookup_df = functools.cache(self.get_lookup_df)
102+
self.get = functools.cache(self.get)
103+
self.get_instance = functools.cache(self.get_instance)
104+
98105
def get_queryset_columns(self):
99106
return [*self.lookup_fields, *self.parent_fields, *self.id_fields]
100107

@@ -108,18 +115,21 @@ def get_queryset(self):
108115
It uses values_list to avoid hydrating a Django model, given that the result will be used
109116
to instantiate a DataFrame.
110117
"""
111-
queryset = self.model.objects.filter(**self.filters).values_list(*self.get_queryset_columns())
118+
queryset = (
119+
self.model.objects.filter(**self.filters)
120+
.exclude(**self.excludes)
121+
.values_list(*self.get_queryset_columns())
122+
)
112123
return queryset
113124

114-
@functools.cache
115125
def get_lookup_df(self):
116126
"""
117127
Build a dataframe for a model that can be used to lookup the primary key.
118128
119129
Create a dataframe that contains the primary key, and all the columns that
120130
can be used to lookup the primary key, e.g. the name, description, aliases, etc.
121131
122-
Use the queryset.iterator() to prevent Django from caching the queryset, and instead use functools.cache to
132+
Use the queryset.iterator() to prevent Django from caching the queryset, because functools.cache is used to
123133
cache the dataframe returned from this function.
124134
"""
125135
df = pd.DataFrame(list(self.get_queryset().iterator()), columns=self.get_queryset_columns())
@@ -322,14 +332,13 @@ def get_instances(self, df, column, related_models=None):
322332
df[column] = df[column].map(model_map)
323333
return df
324334

325-
@functools.cache
326335
def get(self, value: str, **parent_values) -> str | None:
327336
"""
328337
Return the lookup value for a single string, or None if there is no match.
329338
330339
Used to do a lookup for a single value instead of a dataframe.
331340
332-
Unlike `do_lookup()` this is a cached property to avoid repeated database queries.
341+
Unlike `do_lookup()` this is a cached method to avoid repeated database queries.
333342
Note that this cache is per-instance of the Lookup class, so the class should be instantiated outside a loop
334343
performing repeated lookups:
335344
@@ -347,14 +356,13 @@ def get(self, value: str, **parent_values) -> str | None:
347356
except ValueError:
348357
return None
349358

350-
@functools.cache
351359
def get_instance(self, value: str, **parent_values) -> Model | None:
352360
"""
353361
Return the Django model instance for a single string, or None if there is no match.
354362
355363
Used to do a lookup for a single value instead of a dataframe.
356364
357-
Unlike `do_lookup()` this is a cached property to avoid repeated database queries.
365+
Unlike `do_lookup()` this is a cached method to avoid repeated database queries.
358366
Note that this cache is per-instance of the Lookup class, so the class should be instantiated outside a loop
359367
performing repeated lookups:
360368
@@ -444,6 +452,14 @@ class ClassifiedProductLookup(Lookup):
444452
"hs2012",
445453
)
446454

455+
def get_queryset(self):
456+
"""
457+
Exclude specific products that are unwanted duplicates.
458+
"""
459+
# P23162 is Husked Rice, but we use "rice" as an alias for it. This
460+
# conflicts with R0113: Rice, which we never use, so ignore it.
461+
return super().get_queryset().exclude(cpc="R0113")
462+
447463
def get_lookup_df(self):
448464
"""
449465
Build a dataframe for a model that can be used to lookup the primary key.

apps/common/tests/test_lookups.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,21 @@ def test_ignore_unwanted_parents(self):
4040
self.assertEqual(len(result_df), 1)
4141
self.assertEqual(result_df["cpc"][0], product.pk)
4242

43+
def test_excludes_r0113(self):
44+
# Create the unwanted product R0113 with a matching common name
45+
ClassifiedProductFactory(cpc="R0113", common_name_en="Rice", description_en="Rice")
46+
# Create the preferred product that we want the lookup to return, and include 'rice' as an alias
47+
preferred = ClassifiedProductFactory(cpc="P23162", common_name_en="Husked Rice", description_en="Husked Rice")
48+
preferred.aliases = ["arroz", "rice", "riz"]
49+
preferred.save()
50+
51+
df = pd.DataFrame({"product": ["rice"]})
52+
result_df = ClassifiedProductLookup().do_lookup(df, "product", "cpc")
53+
self.assertTrue("cpc" in result_df.columns)
54+
self.assertEqual(len(result_df), 1)
55+
# The lookup should return the preferred product, not the unwanted R0113
56+
self.assertEqual(result_df["cpc"][0], preferred.pk)
57+
4358
# The child record doesn't could have the search term in a the common name instead of the description,
4459
# and the child doesn't need to be first child (with a 0 suffix). The child could have its own children,
4560
# that don't match the search term.

0 commit comments

Comments
 (0)