Skip to content

Commit 0ab06cb

Browse files
authored
Merge pull request #224 from American-Institutes-for-Research/HEA-872/Add-add-cache_memoize-and-etag-decorators
Hea 872/add add cache memoize and etag decorators
2 parents bba70b9 + 0d62ebd commit 0ab06cb

3 files changed

Lines changed: 81 additions & 0 deletions

File tree

apps/baseline/tests/test_viewsets.py

Lines changed: 41 additions & 0 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 (
@@ -677,6 +681,43 @@ def test_as_of_date_filter_returns_valid_baselines(self):
677681
self.assertIn(baseline_no_to.id, baseline_ids)
678682
self.assertIn(baseline_no_dates.id, baseline_ids)
679683

684+
def test_conditional_request_headers(self):
685+
cache.clear() # Clear cache to ensure clean state
686+
687+
# Test that 200 response includes ETag, Last-Modified, Cache-Control, and Expires headers
688+
response = self.client.get(self.url)
689+
self.assertEqual(response.status_code, 200)
690+
self.assertIn("ETag", response.headers)
691+
self.assertIn("Last-Modified", response.headers)
692+
self.assertIn("Cache-Control", response.headers)
693+
self.assertIn("Expires", response.headers)
694+
self.assertTrue(response.headers["ETag"].startswith('W/"')) # Weak ETag format
695+
696+
# Test If-None-Match returns 304 when not modified
697+
etag = response.headers["ETag"]
698+
cache.clear()
699+
response = self.client.get(self.url, HTTP_IF_NONE_MATCH=etag)
700+
self.assertEqual(response.status_code, 304)
701+
self.assertIn("Cache-Control", response.headers)
702+
self.assertIn("Expires", response.headers)
703+
704+
# Test If-None-Match returns 200 when data is modified
705+
cache.clear() # Clear cache before testing modified data
706+
baseline = self.data[0]
707+
baseline.population_source = "Updated source"
708+
baseline.save()
709+
response = self.client.get(self.url, HTTP_IF_NONE_MATCH=etag)
710+
self.assertEqual(response.status_code, 200)
711+
self.assertNotEqual(response.headers["ETag"], etag)
712+
713+
# Test If-Modified-Since with future date returns 304
714+
cache.clear()
715+
future_date = http_date((now() + timedelta(days=1)).timestamp())
716+
response = self.client.get(self.url, HTTP_IF_MODIFIED_SINCE=future_date)
717+
self.assertEqual(response.status_code, 304)
718+
self.assertIn("Cache-Control", response.headers)
719+
self.assertIn("Expires", response.headers)
720+
680721

681722
class LivelihoodZoneBaselineFacetedSearchViewTestCase(APITestCase):
682723
def setUp(self):

apps/baseline/viewsets.py

Lines changed: 12 additions & 0 deletions
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
@@ -14,6 +17,7 @@
1417

1518
from common.fields import translation_fields
1619
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:
@@ -275,6 +282,11 @@ def get_serializer_class(self):
275282
return LivelihoodZoneBaselineGeoSerializer # Use GeoFeatureModelSerializer for GeoJSON
276283
return LivelihoodZoneBaselineSerializer
277284

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+
278290

279291
class LivelihoodProductCategoryFilterSet(filters.FilterSet):
280292
class Meta:

apps/common/utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import contextlib
22
import csv
3+
import hashlib
34
import importlib
45
import logging
56
import re
@@ -12,6 +13,7 @@
1213
from dateutil.relativedelta import relativedelta
1314
from django.apps import apps
1415
from django.db.migrations.operations.base import Operation
16+
from django.db.models import Max
1517
from django.forms.models import modelform_factory
1618
from openpyxl.utils import get_column_letter
1719
from treebeard.mp_tree import MP_Node
@@ -368,3 +370,29 @@ def get_all_subclasses(cls):
368370
# Minimal normalization, doesn't attempt to coerce characters such as æ, which are locale-dependent.
369371
# Example usage: "café".translate(normalize)
370372
normalize = str.maketrans(normal_map)
373+
374+
375+
def make_condition_funcs(model):
376+
"""
377+
Create etag and last_modfied functions for use with Django's condition decorator.
378+
All models inheriting from common.Model have a 'modified' timestamp field from TimeStampedModel.
379+
"""
380+
381+
def get_last_modified(request, *args, **kwargs):
382+
# Return the most recent modification timestamp for the model.
383+
result = model.objects.aggregate(last_modified=Max("modified"))
384+
return result["last_modified"]
385+
386+
def get_etag(request, *args, **kwargs):
387+
"""
388+
Return a weak ETag based on the most recent modification timestamp.
389+
390+
Uses weak ETag (W/ prefix) because we're comparing semantic equivalence of the data,
391+
not byte-for-byte equality. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag
392+
"""
393+
last_modified = get_last_modified(request, *args, **kwargs)
394+
if last_modified is None:
395+
return None
396+
return f'W/"{hashlib.md5(last_modified.isoformat().encode()).hexdigest()}"'
397+
398+
return get_etag, get_last_modified

0 commit comments

Comments
 (0)