Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions rcvis/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
18 changes: 18 additions & 0 deletions visualizer/migrations/0033_add_updated_at.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
1 change: 1 addition & 0 deletions visualizer/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
45 changes: 40 additions & 5 deletions visualizer/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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'
Expand Down