From 94fac0855c558e76f7591d1425c70a5ea9e28def Mon Sep 17 00:00:00 2001 From: skaphan Date: Tue, 24 Feb 2026 13:29:48 -0500 Subject: [PATCH] Add proper cache control for embedded visualizations Add updated_at field to JsonConfig model, use ConditionalGetMixin for all visualization views, and short-circuit 304 responses in VisualizeEmbedded before expensive computation. --- rcvis/settings.py | 1 + visualizer/migrations/0033_add_updated_at.py | 18 ++++++++ visualizer/models.py | 1 + visualizer/views.py | 45 +++++++++++++++++--- 4 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 visualizer/migrations/0033_add_updated_at.py diff --git a/rcvis/settings.py b/rcvis/settings.py index b75da902..a8aaf73e 100644 --- a/rcvis/settings.py +++ b/rcvis/settings.py @@ -84,6 +84,7 @@ # Order of the next 3 is important 'django.middleware.cache.UpdateCacheMiddleware', 'django.middleware.common.CommonMiddleware', + 'django.middleware.http.ConditionalGetMiddleware', 'django.middleware.cache.FetchFromCacheMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', diff --git a/visualizer/migrations/0033_add_updated_at.py b/visualizer/migrations/0033_add_updated_at.py new file mode 100644 index 00000000..4b8255fa --- /dev/null +++ b/visualizer/migrations/0033_add_updated_at.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.28 on 2026-02-22 12:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('visualizer', '0032_jsonconfig_forcefirstrounddeterminespercentages'), + ] + + operations = [ + migrations.AddField( + model_name='jsonconfig', + name='updatedAt', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/visualizer/models.py b/visualizer/models.py index 789dba60..65a6a4e1 100644 --- a/visualizer/models.py +++ b/visualizer/models.py @@ -50,6 +50,7 @@ class JsonConfig(models.Model): candidateSidecarFile = models.FileField(null=True, blank=True) slug = models.SlugField(unique=True, max_length=255) uploadedAt = models.DateTimeField(auto_now_add=True) + updatedAt = models.DateTimeField(auto_now=True) owner = models.ForeignKey( settings.AUTH_USER_MODEL, related_name='this_users_jsons', diff --git a/visualizer/views.py b/visualizer/views.py index d1b51011..a98be80e 100644 --- a/visualizer/views.py +++ b/visualizer/views.py @@ -12,7 +12,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.mixins import LoginRequiredMixin from django.core.cache import cache -from django.http import JsonResponse, HttpResponse +from django.http import JsonResponse, HttpResponse, HttpResponseNotModified from django.shortcuts import render from django.templatetags.static import static from django.urls import Resolver404 @@ -21,6 +21,8 @@ from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.clickjacking import xframe_options_exempt +from django.utils.cache import patch_cache_control +from django.utils.http import http_date, parse_http_date_safe from django.views.decorators.vary import vary_on_headers from django.views.generic.base import TemplateView, RedirectView from django.views.generic.detail import DetailView @@ -160,8 +162,42 @@ def _actions_before_save(self, form): self.model.jsonFile.save('datatablesfile.json', form.cleaned_data['jsonFile']) +class ConditionalGetMixin: + """ + Mixin for DetailView subclasses that serve JsonConfig visualizations. + Short-circuits with 304 Not Modified when the client's If-Modified-Since + matches the object's updatedAt, skipping expensive graph computation + and template rendering. Also sets Last-Modified and Cache-Control on + all responses. + """ + + def get(self, request, *args, **kwargs): + # Fetch object once — setting self.object avoids a second DB query + # when super().get() calls get_object() internally. + self.object = self.get_object() + + # Short-circuit: if the client has a fresh copy, return 304 without + # doing any of the expensive graph computation or template rendering. + if self.object.updatedAt: + last_modified = self.object.updatedAt.timestamp() + if_modified_since = request.META.get('HTTP_IF_MODIFIED_SINCE') + if if_modified_since: + if_modified_since = parse_http_date_safe(if_modified_since) + if if_modified_since is not None and last_modified <= if_modified_since: + response = HttpResponseNotModified() + response['Last-Modified'] = http_date(last_modified) + patch_cache_control(response, no_cache=True, max_age=0) + return response + + response = super().get(request, *args, **kwargs) + if self.object.updatedAt: + response['Last-Modified'] = http_date(self.object.updatedAt.timestamp()) + patch_cache_control(response, no_cache=True, max_age=0) + return response + + @method_decorator(vary_on_headers('increment',), name='get') -class Visualize(DetailView): +class Visualize(ConditionalGetMixin, DetailView): """ Visualizing a single JsonConfig """ model = JsonConfig template_name = 'visualizer/visualize.html' @@ -198,9 +234,8 @@ def get_context_data(self, **kwargs): return data -@method_decorator(vary_on_headers('increment',), name='get') @method_decorator(xframe_options_exempt, name='dispatch') -class VisualizeEmbedded(DetailView): +class VisualizeEmbedded(ConditionalGetMixin, DetailView): """ The embedded visualization, to be used in an iframe. """ @@ -260,7 +295,7 @@ def get_redirect_url(self, *args, **kwargs): @method_decorator(vary_on_headers('increment',), name='get') @method_decorator(xframe_options_exempt, name='dispatch') -class VisualizeBallotpedia(DetailView): +class VisualizeBallotpedia(ConditionalGetMixin, DetailView): """ The embedded ballotpedia visualization """ model = JsonConfig template_name = 'visualizer/visualize-ballotpedia.html'