Skip to content

Commit d3cf776

Browse files
authored
Merge pull request #241 from American-Institutes-for-Research/HEA-787/season_purpose
Support season sets per Livelihood Strategy Type - see HEA-787
2 parents 9bdcb2b + 685b48a commit d3cf776

8 files changed

Lines changed: 276 additions & 4 deletions

File tree

apps/metadata/admin.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ class SeasonAdmin(admin.ModelAdmin):
125125
"aliases",
126126
*translation_fields("description"),
127127
"season_type",
128+
"purpose",
128129
"start",
129130
"end",
130131
"alignment",
@@ -135,17 +136,20 @@ class SeasonAdmin(admin.ModelAdmin):
135136
"name",
136137
"aliases",
137138
"season_type",
139+
"purpose",
138140
"start",
139141
"end",
140142
)
141143
search_fields = (
142144
"country__iso_en_ro_name",
143145
*translation_fields("name"),
144146
"season_type",
147+
"purpose",
145148
)
146149
list_filter = (
147150
("country", admin.RelatedOnlyFieldListFilter),
148151
"season_type",
152+
"purpose",
149153
)
150154
ordering = ("order",)
151155

apps/metadata/lookups.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
reference data in Django Models.
44
"""
55

6+
import pandas as pd
7+
68
from common.fields import translation_fields
79
from common.lookups import Lookup
810

@@ -40,14 +42,69 @@ class WealthGroupCategoryLookup(ReferenceDataLookup):
4042
class SeasonLookup(Lookup):
4143
model = Season
4244
id_fields = ["id"]
43-
# Set parent field to country_id so that we can use 'Season 1' and 'Season 2' aliases in all countries.
44-
parent_fields = ["country_id"]
45+
# Set parent field to include:
46+
# - country_id so that we can use 'Season 1' and 'Season 2' aliases in all countries.
47+
# - purpose so that we can use the 'Season 1' and 'Season 2' alias for different purposes within the same country.
48+
parent_fields = ["country_id", "purpose"]
49+
require_parent_fields = False
4550
lookup_fields = [
4651
*translation_fields("name"),
4752
*translation_fields("description"),
4853
"aliases",
4954
]
5055

56+
def prepare_lookup_df(self) -> pd.DataFrame:
57+
df = super().prepare_lookup_df()
58+
# Seasons with a null purpose can be used to match any purpose for that country that doesn't have specific seasons defined,
59+
# so duplicate any rows with a null purpose for all possible purposes for that country.
60+
all_purposes = [choice[0] for choice in self.model._meta.get_field("purpose").choices]
61+
all_countries = df["country_id"].unique().tolist()
62+
extra_dfs = []
63+
for country in all_countries:
64+
country_df = df[df["country_id"] == country]
65+
null_purpose_rows = country_df[country_df["purpose"].isnull()]
66+
for purpose in all_purposes:
67+
# Only add duplicate rows for purposes that aren't already defined for this country
68+
if purpose not in country_df["purpose"].unique():
69+
purpose_df = null_purpose_rows.copy()
70+
purpose_df["purpose"] = purpose
71+
extra_dfs.append(purpose_df)
72+
if extra_dfs:
73+
df = pd.concat([df] + extra_dfs, ignore_index=True)
74+
return df
75+
76+
def do_lookup(
77+
self,
78+
df: pd.DataFrame,
79+
lookup_column: str = None,
80+
match_column: str = None,
81+
exact_match: bool = True,
82+
update: bool = False,
83+
):
84+
# We need country_id to do the lookup.
85+
if "country_id" not in df.columns:
86+
raise ValueError(f"{self.__class__.__qualname__} is missing parent column(s): country_id")
87+
# We can use purpose or strategy_type as the parent field, and default it to None if not provided.
88+
purpose_provided = "purpose" in df.columns
89+
if not purpose_provided:
90+
# Allow strategy_type as an alias for purpose
91+
if "strategy_type" in df.columns:
92+
df["purpose"] = df["strategy_type"]
93+
# If purpose is not provided, we set it to None so that we can match against seasons with a null purpose.
94+
else:
95+
df["purpose"] = None
96+
97+
df = super().do_lookup(
98+
df,
99+
lookup_column=lookup_column,
100+
match_column=match_column,
101+
exact_match=exact_match,
102+
update=update,
103+
)
104+
if not purpose_provided:
105+
df = df.drop(columns=["purpose"])
106+
return df
107+
51108

52109
class SeasonNameLookup(SeasonLookup):
53110
"""
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Generated by Django 5.2.10 on 2026-02-06 03:45
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("metadata", "0013_wealthcharacteristic_characteristic_group"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="season",
15+
name="purpose",
16+
field=models.CharField(
17+
blank=True,
18+
choices=[
19+
("MilkProduction", "Milk Production"),
20+
("ButterProduction", "Butter Production"),
21+
("MeatProduction", "Meat Production"),
22+
("LivestockSale", "Livestock Sale"),
23+
("CropProduction", "Crop Production"),
24+
("FoodPurchase", "Food Purchase"),
25+
("PaymentInKind", "Payment in Kind"),
26+
("ReliefGiftOther", "Relief, Gift or Other Food"),
27+
("Hunting", "Hunting"),
28+
("Fishing", "Fishing"),
29+
("WildFoodGathering", "Wild Food Gathering"),
30+
("OtherCashIncome", "Other Cash Income"),
31+
("OtherPurchase", "Other Purchase"),
32+
],
33+
help_text="The Livelihood Strategy Type that this Season is relevant for.",
34+
max_length=30,
35+
null=True,
36+
verbose_name="Purpose",
37+
),
38+
),
39+
]

apps/metadata/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,14 @@ class YearAlignment(models.TextChoices):
363363
"Refers to the classification of a specific time of year based on weather patterns, temperature, and other factors" # NOQA: E501
364364
),
365365
)
366+
purpose = models.CharField(
367+
max_length=30,
368+
choices=LivelihoodStrategyType.choices,
369+
blank=True,
370+
null=True,
371+
verbose_name=_("Purpose"),
372+
help_text=_("The Livelihood Strategy Type that this Season is relevant for."),
373+
)
366374
# We use day in the year instead of month to allow greater granularity,
367375
# and compatibility with the potential FDW Enhanced Crop Calendar output.
368376
# Note that if the season goes over the year end, then the start day

apps/metadata/serializers.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,14 @@ def get_end_month(self, obj):
9595

9696
class Meta:
9797
model = Season
98-
fields = ["country", "name", "description", "season_type", "start_month", "end_month", "alignment", "order"]
98+
fields = [
99+
"country",
100+
"name",
101+
"description",
102+
"season_type",
103+
"purpose",
104+
"start_month",
105+
"end_month",
106+
"alignment",
107+
"order",
108+
]

apps/metadata/tests/factories.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ class Meta:
139139
class SeasonFactory(factory.django.DjangoModelFactory):
140140
class Meta:
141141
model = Season
142-
django_get_or_create = ("country", "name_en")
142+
django_get_or_create = ("country", "name_en", "purpose")
143143

144144
name_en = factory.LazyAttributeSequence(lambda o, n: f"{o.country.iso_en_ro_name}, Season {n}")
145145
name_es = factory.LazyAttributeSequence(lambda o, n: f"{o.country.iso_en_ro_name}, Season {n} es")
@@ -153,6 +153,7 @@ class Meta:
153153
description_ar = factory.LazyAttribute(lambda o: f"Description {o.name_ar} {o.season_type}")
154154
country = factory.SubFactory(CountryFactory)
155155
season_type = factory.Iterator([Season.SeasonType.WET, Season.SeasonType.DRY, Season.SeasonType.MILD])
156+
purpose = None
156157
order = factory.Iterator([1, 2, 3])
157158
start = factory.Iterator((25, 95, 200))
158159
end = factory.Iterator((95, 199, 360))
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import pandas as pd
2+
from django.test import TestCase
3+
4+
from common.tests.factories import CountryFactory
5+
from metadata.lookups import SeasonLookup, SeasonNameLookup
6+
from metadata.models import LivelihoodStrategyType
7+
8+
from .factories import SeasonFactory
9+
10+
11+
class SeasonLookupTestCase(TestCase):
12+
def setUp(self):
13+
self.country = CountryFactory()
14+
15+
# season with "Season 1" as an alias but no purpose
16+
self.season1_no_purpose = SeasonFactory(
17+
country=self.country,
18+
name_en="Generic First Season",
19+
aliases=["Season 1"],
20+
purpose=None,
21+
)
22+
23+
# MilkProduction purpose with "Season 1" as an alias
24+
self.season1_milk = SeasonFactory(
25+
country=self.country,
26+
name_en="Milk Production First Season",
27+
aliases=["Season 1"],
28+
purpose=LivelihoodStrategyType.MILK_PRODUCTION,
29+
)
30+
31+
# CropProduction purpose with "Season 1" as an alias
32+
self.season1_crop = SeasonFactory(
33+
country=self.country,
34+
name_en="Crop Production First Season",
35+
aliases=["Season 1"],
36+
purpose=LivelihoodStrategyType.CROP_PRODUCTION,
37+
)
38+
39+
def test_lookup_without_purpose(self):
40+
# when no purpose is provided, should match only seasons with null purpose
41+
df = pd.DataFrame(
42+
{
43+
"season": ["Season 1"],
44+
"country_id": [self.country.iso3166a2],
45+
}
46+
)
47+
result_df = SeasonLookup().do_lookup(df, "season", "season_id")
48+
self.assertTrue("season_id" in result_df.columns)
49+
self.assertEqual(len(result_df), 1)
50+
self.assertEqual(result_df["season_id"][0], self.season1_no_purpose.id)
51+
52+
def test_lookup_with_matching_purpose(self):
53+
# test when purpose is provided and matches a season's purpose
54+
df = pd.DataFrame(
55+
{
56+
"season": ["Season 1"],
57+
"country_id": [self.country.iso3166a2],
58+
"purpose": [LivelihoodStrategyType.MILK_PRODUCTION],
59+
}
60+
)
61+
result_df = SeasonLookup().do_lookup(df, "season", "season_id")
62+
self.assertTrue("season_id" in result_df.columns)
63+
self.assertEqual(len(result_df), 1)
64+
self.assertEqual(result_df["season_id"][0], self.season1_milk.id)
65+
66+
def test_lookup_with_strategy_type_alias(self):
67+
# test when strategy_type is provided and matches a season's purpose
68+
df = pd.DataFrame(
69+
{
70+
"season": ["Season 1"],
71+
"country_id": [self.country.iso3166a2],
72+
"strategy_type": [LivelihoodStrategyType.MILK_PRODUCTION],
73+
}
74+
)
75+
result_df = SeasonLookup().do_lookup(df, "season", "season_id")
76+
self.assertTrue("season_id" in result_df.columns)
77+
self.assertEqual(len(result_df), 1)
78+
self.assertEqual(result_df["season_id"][0], self.season1_milk.id)
79+
80+
def test_lookup_with_non_matching_purpose_falls_back(self):
81+
# test when purpose doesn't match any season's purpose, should fall back Seasons with null purpose
82+
df = pd.DataFrame(
83+
{
84+
"season": ["Season 1"],
85+
"country_id": [self.country.iso3166a2],
86+
"purpose": [LivelihoodStrategyType.LIVESTOCK_SALE],
87+
}
88+
)
89+
result_df = SeasonLookup().do_lookup(df, "season", "season_id")
90+
self.assertTrue("season_id" in result_df.columns)
91+
self.assertEqual(len(result_df), 1)
92+
self.assertEqual(result_df["season_id"][0], self.season1_no_purpose.id)
93+
94+
def test_season_name_lookup_with_purpose(self):
95+
df = pd.DataFrame(
96+
{
97+
"season": ["Season 1"],
98+
"country_id": [self.country.iso3166a2],
99+
"purpose": [LivelihoodStrategyType.MILK_PRODUCTION],
100+
}
101+
)
102+
result_df = SeasonNameLookup().do_lookup(df, "season", "season_name")
103+
self.assertTrue("season_name" in result_df.columns)
104+
self.assertEqual(len(result_df), 1)
105+
self.assertEqual(result_df["season_name"][0], self.season1_milk.name_en)
106+
107+
def test_lookup_raises_error_with_duplicate_null_purpose(self):
108+
# test when multiple seasons have null purpose and same alias, should raise error
109+
SeasonFactory(
110+
country=self.country,
111+
name_en="Another Generic First Season",
112+
aliases=["Season 1"],
113+
purpose=None,
114+
)
115+
116+
df = pd.DataFrame(
117+
{
118+
"season": ["Season 1"],
119+
"country_id": [self.country.iso3166a2],
120+
}
121+
)
122+
123+
with self.assertRaises(ValueError) as context:
124+
SeasonLookup().do_lookup(df, "season", "season_id")
125+
126+
self.assertIn("found multiple Season matches", str(context.exception))
127+
128+
def test_lookup_raises_error_with_duplicate_same_purpose(self):
129+
# test when multiple seasons have same purpose and same alias, should raise error
130+
SeasonFactory(
131+
country=self.country,
132+
name_en="Another Milk Production Season",
133+
aliases=["Season 1"],
134+
purpose=LivelihoodStrategyType.MILK_PRODUCTION,
135+
)
136+
137+
df = pd.DataFrame(
138+
{
139+
"season": ["Season 1"],
140+
"country_id": [self.country.iso3166a2],
141+
"purpose": [LivelihoodStrategyType.MILK_PRODUCTION],
142+
}
143+
)
144+
145+
with self.assertRaises(ValueError) as context:
146+
SeasonLookup().do_lookup(df, "season", "season_id")
147+
148+
self.assertIn("found multiple Season matches", str(context.exception))

apps/metadata/viewsets.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from metadata.models import (
1212
HazardCategory,
1313
LivelihoodCategory,
14+
LivelihoodStrategyType,
1415
ReferenceData,
1516
Season,
1617
SeasonalActivityType,
@@ -194,6 +195,9 @@ def __init__(self, *args, **kwargs):
194195
season_type = filters.ChoiceFilter(
195196
choices=Season.SeasonType.choices,
196197
)
198+
purpose = filters.ChoiceFilter(
199+
choices=LivelihoodStrategyType.choices,
200+
)
197201
name_en = filters.CharFilter(lookup_expr="icontains", label=format_lazy("{} ({})", _("Name"), _("English")))
198202
name_fr = filters.CharFilter(lookup_expr="icontains", label=format_lazy("{} ({})", _("Name"), _("French")))
199203
name_es = filters.CharFilter(lookup_expr="icontains", label=format_lazy("{} ({})", _("Name"), _("Spanish")))
@@ -232,4 +236,5 @@ class SeasonViewSet(BaseModelViewSet):
232236
*translation_fields("name"),
233237
*translation_fields("description"),
234238
"season_type",
239+
"purpose",
235240
)

0 commit comments

Comments
 (0)