Skip to content

Commit ecef5bd

Browse files
committed
Merge branch 'main' into HEA-809/add_regex_for_men_women_boys_girls
2 parents 9914aea + 1cb423b commit ecef5bd

15 files changed

Lines changed: 866 additions & 389 deletions

File tree

apps/baseline/models.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Models for managing HEA Baseline Surveys
33
"""
44

5+
import datetime
56
import numbers
67

78
from django.conf import settings
@@ -131,7 +132,30 @@ class ExtraMeta:
131132
identifier = ["code"]
132133

133134

134-
class LivelihoodZoneBaselineManager(common_models.IdentifierManager):
135+
class LivelihoodZoneBaselineQuerySet(models.QuerySet):
136+
"""
137+
QuerySet for LivelihoodZoneBaseline that provides temporal filtering methods.
138+
"""
139+
140+
def filter_current(self, as_of_date=None):
141+
"""
142+
Return a queryset filtered to the baselines that are valid as of the date specified.
143+
"""
144+
if not as_of_date:
145+
as_of_date = datetime.date.today()
146+
return self.filter(
147+
(models.Q(valid_from_date__lte=as_of_date) | models.Q(valid_from_date__isnull=True))
148+
& (models.Q(valid_to_date__gte=as_of_date) | models.Q(valid_to_date__isnull=True))
149+
)
150+
151+
def current_all(self, as_of_date=None):
152+
"""
153+
Return all the baselines that are valid as of the date specified.
154+
"""
155+
return self.filter_current(as_of_date).all()
156+
157+
158+
class LivelihoodZoneBaselineManager(common_models.IdentifierManager.from_queryset(LivelihoodZoneBaselineQuerySet)):
135159
def get_by_natural_key(self, code: str, reference_year_end_date: str):
136160
return self.get(livelihood_zone__code=code, reference_year_end_date=reference_year_end_date)
137161

apps/baseline/serializers.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,11 @@ def get_characteristic_group(self, obj):
438438
"""
439439
if obj.product and obj.product.cpc.startswith("L02"):
440440
return "Livestock"
441-
return obj.wealth_characteristic.characteristic_group
441+
return (
442+
obj.wealth_characteristic.characteristic_group.code
443+
if obj.wealth_characteristic.characteristic_group
444+
else None
445+
)
442446

443447
product_common_name = serializers.CharField(source="product.common_name", read_only=True, allow_null=True)
444448
unit_of_measure_description = serializers.CharField(
@@ -542,7 +546,11 @@ def get_characteristic_group(self, obj):
542546
"""
543547
if obj.product and obj.product.cpc.startswith("L02"):
544548
return "Livestock"
545-
return obj.wealth_characteristic.characteristic_group
549+
return (
550+
obj.wealth_characteristic.characteristic_group.code
551+
if obj.wealth_characteristic.characteristic_group
552+
else None
553+
)
546554

547555
product_common_name = serializers.CharField(source="product.common_name", read_only=True, allow_null=True)
548556
unit_of_measure_description = serializers.CharField(

apps/baseline/tests/factories.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,9 @@ class Meta:
119119
bss_language = factory.Iterator(["en", "pt", "es", "ar", "fr"])
120120
reference_year_start_date = factory.LazyAttribute(lambda o: o.reference_year_end_date - relativedelta(years=1))
121121
reference_year_end_date = factory.Sequence(lambda n: datetime.date(1900, 1, 1) + datetime.timedelta(days=n + 10))
122-
valid_from_date = factory.Sequence(lambda n: datetime.date(1900, 1, 1) + datetime.timedelta(days=n))
123-
valid_to_date = factory.Sequence(lambda n: datetime.date(1900, 1, 1) + datetime.timedelta(days=n + 10))
122+
# Default to None so factory-created baselines are always valid regardless of as_of_date filter
123+
valid_from_date = None
124+
valid_to_date = None
124125
population_source = factory.Sequence(lambda n: f"population_source {n}")
125126
population_estimate = fuzzy.FuzzyInteger(500, 1000000)
126127
currency = factory.SubFactory(CurrencyFactory)

apps/baseline/tests/test_viewsets.py

Lines changed: 133 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22
import json
33
import logging
44
import warnings
5+
from datetime import timedelta
56
from io import StringIO
67

78
import pandas as pd
89
from bs4 import BeautifulSoup
910
from django.contrib.auth.models import User
11+
from django.core.cache import cache
1012
from django.db.models import F
1113
from django.urls import reverse
14+
from django.utils.http import http_date
15+
from django.utils.timezone import now
1216
from rest_framework.test import APITestCase
1317

1418
from baseline.models import (
@@ -19,6 +23,7 @@
1923
from common.tests.factories import ClassifiedProductFactory, CountryFactory
2024
from metadata.models import LivelihoodActivityScenario
2125
from metadata.tests.factories import (
26+
CharacteristicGroupFactory,
2227
LivelihoodCategoryFactory,
2328
WealthCharacteristicFactory,
2429
WealthGroupCategoryFactory,
@@ -595,6 +600,125 @@ def test_filter_by_wealth_characteristic(self):
595600
self.assertEqual(response.status_code, 200)
596601
self.assertEqual(len(response.json()), 1)
597602

603+
def test_as_of_date_filter_returns_valid_baselines(self):
604+
"""
605+
Test that the as_of_date filter returns only baselines valid as of the specified date.
606+
"""
607+
today = datetime.date.today()
608+
609+
# Baseline that expired in the past
610+
expired_baseline = LivelihoodZoneBaselineFactory(
611+
valid_from_date=today - datetime.timedelta(days=365),
612+
valid_to_date=today - datetime.timedelta(days=30),
613+
)
614+
615+
# Baseline currently valid
616+
current_baseline = LivelihoodZoneBaselineFactory(
617+
valid_from_date=today - datetime.timedelta(days=30),
618+
valid_to_date=today + datetime.timedelta(days=365),
619+
)
620+
621+
# Baseline that starts in the future (not yet valid)
622+
future_baseline = LivelihoodZoneBaselineFactory(
623+
valid_from_date=today + datetime.timedelta(days=30),
624+
valid_to_date=today + datetime.timedelta(days=365),
625+
)
626+
627+
# Test default behavior (no as_of_date param) - should return all baselines
628+
response = self.client.get(self.url)
629+
self.assertEqual(response.status_code, 200)
630+
baseline_ids = [b["id"] for b in response.json()]
631+
self.assertIn(current_baseline.id, baseline_ids)
632+
self.assertIn(expired_baseline.id, baseline_ids)
633+
self.assertIn(future_baseline.id, baseline_ids)
634+
635+
# Test with explicit as_of_date=today - should filter to current baselines
636+
response = self.client.get(self.url, {"as_of_date": today.isoformat()})
637+
self.assertEqual(response.status_code, 200)
638+
baseline_ids = [b["id"] for b in response.json()]
639+
self.assertIn(current_baseline.id, baseline_ids)
640+
self.assertNotIn(expired_baseline.id, baseline_ids)
641+
self.assertNotIn(future_baseline.id, baseline_ids)
642+
643+
# Test with a past date when expired_baseline was still valid
644+
# current_baseline hadn't started yet, so it should be excluded
645+
past_date = today - datetime.timedelta(days=180)
646+
response = self.client.get(self.url, {"as_of_date": past_date.isoformat()})
647+
self.assertEqual(response.status_code, 200)
648+
baseline_ids = [b["id"] for b in response.json()]
649+
self.assertIn(expired_baseline.id, baseline_ids)
650+
self.assertNotIn(current_baseline.id, baseline_ids) # hadn't started yet
651+
self.assertNotIn(future_baseline.id, baseline_ids)
652+
653+
# Test with a future date when future_baseline will be valid
654+
future_date = today + datetime.timedelta(days=60)
655+
response = self.client.get(self.url, {"as_of_date": future_date.isoformat()})
656+
self.assertEqual(response.status_code, 200)
657+
baseline_ids = [b["id"] for b in response.json()]
658+
self.assertNotIn(expired_baseline.id, baseline_ids)
659+
self.assertIn(current_baseline.id, baseline_ids)
660+
self.assertIn(future_baseline.id, baseline_ids)
661+
662+
# Baseline with null valid_from_date (valid from the beginning of time)
663+
baseline_no_from = LivelihoodZoneBaselineFactory(
664+
valid_from_date=None,
665+
valid_to_date=today + datetime.timedelta(days=365),
666+
)
667+
# Baseline with null valid_to_date (valid indefinitely)
668+
baseline_no_to = LivelihoodZoneBaselineFactory(
669+
valid_from_date=today - datetime.timedelta(days=365),
670+
valid_to_date=None,
671+
)
672+
# Baseline with both dates null (always valid)
673+
baseline_no_dates = LivelihoodZoneBaselineFactory(
674+
valid_from_date=None,
675+
valid_to_date=None,
676+
)
677+
# Test default behavior - all three should be returned
678+
response = self.client.get(self.url)
679+
self.assertEqual(response.status_code, 200)
680+
baseline_ids = [b["id"] for b in response.json()]
681+
self.assertIn(baseline_no_from.id, baseline_ids)
682+
self.assertIn(baseline_no_to.id, baseline_ids)
683+
self.assertIn(baseline_no_dates.id, baseline_ids)
684+
685+
def test_conditional_request_headers(self):
686+
cache.clear() # Clear cache to ensure clean state
687+
688+
# Test that 200 response includes ETag, Last-Modified, Cache-Control, and Expires headers
689+
response = self.client.get(self.url)
690+
self.assertEqual(response.status_code, 200)
691+
self.assertIn("ETag", response.headers)
692+
self.assertIn("Last-Modified", response.headers)
693+
self.assertIn("Cache-Control", response.headers)
694+
self.assertIn("Expires", response.headers)
695+
self.assertTrue(response.headers["ETag"].startswith('W/"')) # Weak ETag format
696+
697+
# Test If-None-Match returns 304 when not modified
698+
etag = response.headers["ETag"]
699+
cache.clear()
700+
response = self.client.get(self.url, HTTP_IF_NONE_MATCH=etag)
701+
self.assertEqual(response.status_code, 304)
702+
self.assertIn("Cache-Control", response.headers)
703+
self.assertIn("Expires", response.headers)
704+
705+
# Test If-None-Match returns 200 when data is modified
706+
cache.clear() # Clear cache before testing modified data
707+
baseline = self.data[0]
708+
baseline.population_source = "Updated source"
709+
baseline.save()
710+
response = self.client.get(self.url, HTTP_IF_NONE_MATCH=etag)
711+
self.assertEqual(response.status_code, 200)
712+
self.assertNotEqual(response.headers["ETag"], etag)
713+
714+
# Test If-Modified-Since with future date returns 304
715+
cache.clear()
716+
future_date = http_date((now() + timedelta(days=1)).timestamp())
717+
response = self.client.get(self.url, HTTP_IF_MODIFIED_SINCE=future_date)
718+
self.assertEqual(response.status_code, 304)
719+
self.assertIn("Cache-Control", response.headers)
720+
self.assertIn("Expires", response.headers)
721+
598722

599723
class LivelihoodZoneBaselineFacetedSearchViewTestCase(APITestCase):
600724
def setUp(self):
@@ -641,7 +765,8 @@ def test_search_with_product(self):
641765
# Apply the filters to the baseline
642766
baseline_url = reverse("livelihoodzonebaseline-list")
643767
response = self.client.get(
644-
baseline_url, {search_data["products"][0]["filter"]: search_data["products"][0]["value"]}
768+
baseline_url,
769+
{search_data["products"][0]["filter"]: search_data["products"][0]["value"]},
645770
)
646771
self.assertEqual(response.status_code, 200)
647772
self.assertEqual(len(json.loads(response.content)), 2)
@@ -650,7 +775,10 @@ def test_search_with_product(self):
650775
self.assertTrue(any(d["name"] == self.baseline3.name for d in data))
651776
self.assertFalse(any(d["name"] == self.baseline2.name for d in data))
652777

653-
response = self.client.get(baseline_url, {search_data["items"][0]["filter"]: search_data["items"][0]["value"]})
778+
response = self.client.get(
779+
baseline_url,
780+
{search_data["items"][0]["filter"]: search_data["items"][0]["value"]},
781+
)
654782
self.assertEqual(response.status_code, 200)
655783
self.assertEqual(len(json.loads(response.content)), 1)
656784
data = json.loads(response.content)
@@ -1499,11 +1627,13 @@ def setUpTestData(cls):
14991627
wealth_group_category=cls.very_poor_wg,
15001628
community=cls.community,
15011629
)
1630+
# Create characteristic group
1631+
cls.population_group = CharacteristicGroupFactory(code="Population", name_en="Population")
15021632
# Create wealth characteristics
15031633
cls.char_with_group = WealthCharacteristicFactory(
15041634
code="household size",
15051635
name_en="Household size",
1506-
characteristic_group="Population",
1636+
characteristic_group=cls.population_group,
15071637
)
15081638
cls.char_without_group = WealthCharacteristicFactory(
15091639
code="other",

apps/baseline/viewsets.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
from django.db.models import Expression, F, Q, Subquery, TextField, Value
55
from django.db.models.functions import Coalesce, NullIf
66
from django.utils import translation
7+
from django.utils.decorators import method_decorator
78
from django.utils.translation import override
9+
from django.views.decorators.cache import cache_page
10+
from django.views.decorators.http import condition
811
from django_filters import rest_framework as filters
912
from django_filters.filters import CharFilter
1013
from rest_framework.permissions import AllowAny
@@ -13,7 +16,8 @@
1316
from rest_framework.views import APIView
1417

1518
from common.fields import translation_fields
16-
from common.filters import MultiFieldFilter, UpperCaseFilter
19+
from common.filters import DefaultingDateFilter, MultiFieldFilter, UpperCaseFilter
20+
from common.utils import make_condition_funcs
1721
from common.viewsets import AggregatingViewSet, BaseModelViewSet
1822
from metadata.models import WealthGroupCategory
1923

@@ -100,6 +104,9 @@
100104
WildFoodGatheringSerializer,
101105
)
102106

107+
# Create condition functions for LivelihoodZoneBaseline endpoint caching
108+
get_baseline_etag, get_baseline_last_modified = make_condition_funcs(LivelihoodZoneBaseline)
109+
103110

104111
class SourceOrganizationFilterSet(filters.FilterSet):
105112
class Meta:
@@ -205,6 +212,10 @@ class Meta:
205212
wealth_characteristic = CharFilter(
206213
method="filter_by_wealth_characteristic", label="Filter by Wealth Characteristic"
207214
)
215+
as_of_date = DefaultingDateFilter(
216+
label="As of Date",
217+
help_text="Filter baselines valid as of this date (YYYY-MM-DD format or special values like 'today').",
218+
)
208219

209220
def filter_by_product(self, queryset, name, value):
210221
"""
@@ -271,6 +282,11 @@ def get_serializer_class(self):
271282
return LivelihoodZoneBaselineGeoSerializer # Use GeoFeatureModelSerializer for GeoJSON
272283
return LivelihoodZoneBaselineSerializer
273284

285+
@method_decorator(cache_page(60 * 60 * 24)) # Cache on server for 24 hours - must be above condition per RFC 9110
286+
@method_decorator(condition(etag_func=get_baseline_etag, last_modified_func=get_baseline_last_modified))
287+
def list(self, request, *args, **kwargs):
288+
return super().list(request, *args, **kwargs)
289+
274290

275291
class LivelihoodProductCategoryFilterSet(filters.FilterSet):
276292
class Meta:

apps/common/filters.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@
88
from django.core.validators import EMPTY_VALUES
99
from django.db.models import F, Func, Q
1010
from django.forms import TextInput
11-
from django.forms.fields import ChoiceField, Field, MultipleChoiceField
11+
from django.forms.fields import ChoiceField, DateField, Field, MultipleChoiceField
1212
from django.forms.models import ModelMultipleChoiceField
1313
from django.utils.datastructures import MultiValueDict
1414
from django.utils.encoding import force_str
1515
from django_filters import ModelMultipleChoiceFilter, MultipleChoiceFilter
16-
from django_filters.filters import BooleanFilter, CharFilter, ChoiceFilter
16+
from django_filters.filters import BooleanFilter, CharFilter, ChoiceFilter, DateFilter
1717
from rest_framework.filters import OrderingFilter
1818

19+
from .utils import DEFAULT_DATES
20+
1921
logger = logging.getLogger(__name__)
2022

2123

@@ -238,3 +240,33 @@ def filter(self, qs, value):
238240
method = qs.exclude if exclude else qs.filter
239241

240242
return method(**{self.field_name: ""})
243+
244+
245+
class DefaultingDateField(DateField):
246+
"""
247+
A date field that accepts defaults like "last_month", "today"
248+
"""
249+
250+
def to_python(self, value):
251+
if value in DEFAULT_DATES:
252+
value = DEFAULT_DATES[value]()
253+
return super().to_python(value)
254+
255+
256+
class DefaultingDateFilter(DateFilter):
257+
"""
258+
A date filter that accepts defaults like "today"
259+
"""
260+
261+
field_class = DefaultingDateField
262+
263+
def filter(self, qs, value):
264+
if value:
265+
if self.lookup_expr in ["lte", "gte"]:
266+
# period_date filter for start and end date with either gte or lte expression will fall here.
267+
query = Q()
268+
query = Q(**{self.field_name + "__" + self.lookup_expr: value})
269+
return qs.filter(query)
270+
else:
271+
return qs.current_all(value)
272+
return qs

0 commit comments

Comments
 (0)