Skip to content

Commit 281c502

Browse files
committed
Address PR feedback with condition etag functions instead of older approach using model modified field see HEA-872
1 parent 27e882e commit 281c502

3 files changed

Lines changed: 58 additions & 72 deletions

File tree

apps/baseline/tests/test_viewsets.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import json
22
import logging
33
import warnings
4+
from datetime import timedelta
45
from io import StringIO
56

67
import pandas as pd
78
from bs4 import BeautifulSoup
89
from django.contrib.auth.models import User
10+
from django.core.cache import cache
911
from django.urls import reverse
12+
from django.utils.http import http_date
13+
from django.utils.timezone import now
1014
from rest_framework.test import APITestCase
1115

1216
from baseline.models import LivelihoodZoneBaseline
@@ -588,6 +592,35 @@ def test_filter_by_wealth_characteristic(self):
588592
self.assertEqual(response.status_code, 200)
589593
self.assertEqual(len(response.json()), 1)
590594

595+
def test_conditional_request_headers(self):
596+
597+
cache.clear() # Clear cache to ensure clean state
598+
# Test that response includes ETag and Last-Modified headers
599+
response = self.client.get(self.url)
600+
self.assertEqual(response.status_code, 200)
601+
self.assertIn("ETag", response.headers)
602+
self.assertIn("Last-Modified", response.headers)
603+
self.assertTrue(response.headers["ETag"].startswith('W/"')) # Weak ETag format
604+
605+
# Test If-None-Match returns 304 when not modified
606+
etag = response.headers["ETag"]
607+
response = self.client.get(self.url, HTTP_IF_NONE_MATCH=etag)
608+
self.assertEqual(response.status_code, 304)
609+
610+
# Test If-None-Match returns 200 when data is modified
611+
cache.clear() # Clear cache before testing modified data
612+
baseline = self.data[0]
613+
baseline.population_source = "Updated source"
614+
baseline.save()
615+
response = self.client.get(self.url, HTTP_IF_NONE_MATCH=etag)
616+
self.assertEqual(response.status_code, 200)
617+
self.assertNotEqual(response.headers["ETag"], etag)
618+
619+
# Test If-Modified-Since with future date returns 304
620+
future_date = http_date((now() + timedelta(days=1)).timestamp())
621+
response = self.client.get(self.url, HTTP_IF_MODIFIED_SINCE=future_date)
622+
self.assertEqual(response.status_code, 304)
623+
591624

592625
class LivelihoodZoneBaselineFacetedSearchViewTestCase(APITestCase):
593626
def setUp(self):

apps/baseline/viewsets.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.utils.decorators import method_decorator
66
from django.utils.translation import override
77
from django.views.decorators.cache import cache_page
8-
from django.views.decorators.http import etag
8+
from django.views.decorators.http import condition
99
from django_filters import rest_framework as filters
1010
from django_filters.filters import CharFilter
1111
from rest_framework.permissions import AllowAny
@@ -15,7 +15,7 @@
1515

1616
from common.fields import translation_fields
1717
from common.filters import MultiFieldFilter, UpperCaseFilter
18-
from common.utils import get_etag_for_cachedrequest
18+
from common.utils import make_condition_funcs
1919
from common.viewsets import AggregatingViewSet, BaseModelViewSet
2020

2121
from .models import (
@@ -97,6 +97,9 @@
9797
WildFoodGatheringSerializer,
9898
)
9999

100+
# Create condition functions for LivelihoodZoneBaseline endpoint caching
101+
get_baseline_etag, get_baseline_last_modified = make_condition_funcs(LivelihoodZoneBaseline)
102+
100103

101104
class SourceOrganizationFilterSet(filters.FilterSet):
102105
class Meta:
@@ -268,8 +271,8 @@ def get_serializer_class(self):
268271
return LivelihoodZoneBaselineGeoSerializer # Use GeoFeatureModelSerializer for GeoJSON
269272
return LivelihoodZoneBaselineSerializer
270273

274+
@method_decorator(condition(etag_func=get_baseline_etag, last_modified_func=get_baseline_last_modified))
271275
@method_decorator(cache_page(60 * 60 * 24)) # Cache on server for 24 hours
272-
@method_decorator(etag(get_etag_for_cachedrequest))
273276
def list(self, request, *args, **kwargs):
274277
return super().list(request, *args, **kwargs)
275278

apps/common/utils.py

Lines changed: 19 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,12 @@
88
from datetime import datetime, timedelta
99
from io import BytesIO, StringIO
1010
from pathlib import Path
11-
from urllib.parse import parse_qs, urlencode, urlparse
1211

1312
import pandas as pd
1413
from django.apps import apps
1514
from django.db.migrations.operations.base import Operation
15+
from django.db.models import Max
1616
from django.forms.models import modelform_factory
17-
from django.utils.cache import set_response_etag
18-
from django.utils.timezone import now
1917
from openpyxl.utils import get_column_letter
2018
from treebeard.mp_tree import MP_Node
2119

@@ -355,75 +353,27 @@ def get_all_subclasses(cls):
355353
normalize = str.maketrans(normal_map)
356354

357355

358-
class Timer:
356+
def make_condition_funcs(model):
359357
"""
360-
See http://www.machinalis.com/blog/how-to-unit-test-python/
358+
Create etag and last_modfied functions for use with Django's condition decorator.
359+
All models inheriting from common.Model have a 'modified' timestamp field from TimeStampedModel.
361360
"""
362361

363-
def start(self):
364-
self._start = now()
362+
def get_last_modified(request, *args, **kwargs):
363+
# Return the most recent modification timestamp for the model.
364+
result = model.objects.aggregate(last_modified=Max("modified"))
365+
return result["last_modified"]
365366

366-
def stop(self):
367-
self._stop = now()
367+
def get_etag(request, *args, **kwargs):
368+
"""
369+
Return a weak ETag based on the most recent modification timestamp.
368370
369-
def elapsed(self):
370-
try:
371-
return self._stop - self._start
372-
except AttributeError:
373-
return now() - self._start
374-
375-
def assertHasDate(self, date):
376-
assert self._start <= date <= self._stop, "%s was not during the timer" % date
377-
378-
def assertNotHasDate(self, date):
379-
assert not (self._start <= date <= self._stop), "%s was during the timer" % date
380-
381-
382-
@contextlib.contextmanager
383-
def timekeeper():
384-
t = Timer()
385-
t.start()
386-
try:
387-
yield t
388-
finally:
389-
t.stop()
390-
391-
392-
def get_etag_for_cachedrequest(request, *args, **kwargs):
393-
"""
394-
Generate an ETag for the request based on path and query parameters.
395-
If the request includes a _refresh parameter, returns None to force a cache miss.
396-
"""
397-
u = urlparse(request.get_full_path())
398-
query = parse_qs(u.query, keep_blank_values=True)
399-
query.pop("_store_result", None)
400-
401-
# Check for refresh parameter - if present, return None to force cache miss
402-
_refresh = query.pop("_refresh", None)
403-
if _refresh:
404-
return None
405-
406-
u = u._replace(query=urlencode(sorted(query.items()), True))
407-
path = u.geturl()
408-
409-
if "format" in kwargs:
410-
path += f"|format={kwargs['format']}"
411-
412-
etag_hash = hashlib.md5(path.encode()).hexdigest()
413-
414-
return f'"{etag_hash}"'
415-
416-
417-
def set_etag_for_response(response):
418-
"""
419-
Add the etag header to a response.
420-
421-
This is a thin wrapper around `django.utils.cache.set_response_etag`, to report
422-
the time taken to calculate the header.
423-
"""
424-
with timekeeper() as t:
425-
response = set_response_etag(response)
426-
if response.has_header("ETag"):
427-
logger.info("Created etag header in %s seconds" % round(t.elapsed().total_seconds(), 2))
371+
Uses weak ETag (W/ prefix) because we're comparing semantic equivalence of the data,
372+
not byte-for-byte equality. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag
373+
"""
374+
last_modified = get_last_modified(request, *args, **kwargs)
375+
if last_modified is None:
376+
return None
377+
return f'W/"{hashlib.md5(last_modified.isoformat().encode()).hexdigest()}"'
428378

429-
return response
379+
return get_etag, get_last_modified

0 commit comments

Comments
 (0)