From 94fac0855c558e76f7591d1425c70a5ea9e28def Mon Sep 17 00:00:00 2001 From: skaphan Date: Tue, 24 Feb 2026 13:29:48 -0500 Subject: [PATCH 1/3] 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' From 032e992624c872069b41fa2e6f5f5834bf67e2c6 Mon Sep 17 00:00:00 2001 From: skaphan Date: Wed, 4 Mar 2026 21:31:34 -0500 Subject: [PATCH 2/3] Fix linter issues in ConditionalGetMixin - Add pylint disable for too-few-public-methods (it is a mixin) - Add docstring to get() method - Rename last_modified/if_modified_since to camelCase Co-Authored-By: Claude Opus 4.6 --- visualizer/views.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/visualizer/views.py b/visualizer/views.py index a98be80e..2a2c8cee 100644 --- a/visualizer/views.py +++ b/visualizer/views.py @@ -162,7 +162,7 @@ def _actions_before_save(self, form): self.model.jsonFile.save('datatablesfile.json', form.cleaned_data['jsonFile']) -class ConditionalGetMixin: +class ConditionalGetMixin: # pylint: disable=too-few-public-methods """ Mixin for DetailView subclasses that serve JsonConfig visualizations. Short-circuits with 304 Not Modified when the client's If-Modified-Since @@ -172,6 +172,7 @@ class ConditionalGetMixin: """ def get(self, request, *args, **kwargs): + """Return 304 if the client's copy is fresh, otherwise render normally.""" # Fetch object once — setting self.object avoids a second DB query # when super().get() calls get_object() internally. self.object = self.get_object() @@ -179,13 +180,13 @@ def get(self, request, *args, **kwargs): # 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: + lastModified = self.object.updatedAt.timestamp() + ifModifiedSince = request.META.get('HTTP_IF_MODIFIED_SINCE') + if ifModifiedSince: + ifModifiedSince = parse_http_date_safe(ifModifiedSince) + if ifModifiedSince is not None and lastModified <= ifModifiedSince: response = HttpResponseNotModified() - response['Last-Modified'] = http_date(last_modified) + response['Last-Modified'] = http_date(lastModified) patch_cache_control(response, no_cache=True, max_age=0) return response From 70693e620d05195b66d3411650c45f15a095a028 Mon Sep 17 00:00:00 2001 From: skaphan Date: Thu, 5 Mar 2026 06:13:26 -0500 Subject: [PATCH 3/3] Add reset-db.sh for easy database reset after branch switching Co-Authored-By: Claude Opus 4.6 --- scripts/reset-db.sh | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100755 scripts/reset-db.sh diff --git a/scripts/reset-db.sh b/scripts/reset-db.sh new file mode 100755 index 00000000..24f378fa --- /dev/null +++ b/scripts/reset-db.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Reset the SQLite database and re-run all migrations. +# Use after switching branches with incompatible migration history. +set -e + +source venv/bin/activate +source .env + +rm -f db.sqlite3 +python manage.py migrate + +# Create API-enabled admin user (matches docker-entrypoint.sh) +python manage.py shell -c " +from django.contrib.auth import get_user_model +User = get_user_model() +if not User.objects.filter(username='skaphan').exists(): + user = User.objects.create_superuser('skaphan', 'sjk@kaphan.org', 'rcvisacc0unt') + user.userprofile.canUseApi = True + user.userprofile.save() + print('Created API user skaphan with API access') +else: + print('API user skaphan already exists') +" + +echo "Database reset complete."