From 75decde51204acb5e5b958aa193ebfc234825619 Mon Sep 17 00:00:00 2001 From: Stefano Rivera Date: Fri, 23 Jul 2021 15:59:22 -0400 Subject: [PATCH 1/3] Fix typo --- wafer/schedule/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wafer/schedule/models.py b/wafer/schedule/models.py index 43276c90..dbfe0671 100644 --- a/wafer/schedule/models.py +++ b/wafer/schedule/models.py @@ -381,7 +381,7 @@ def update_schedule_items(*args, **kw): return for item in slot.scheduleitem_set.all(): item.save(update_fields=['last_updated']) - # We also need to update the next slot, in case we changed it's + # We also need to update the next slot, in case we changed its # times as well next_slot = slot.slot_set.all() if next_slot.count(): From 08b143b8eb9ca976927fcf145d54049a4777e524 Mon Sep 17 00:00:00 2001 From: Stefano Rivera Date: Fri, 23 Jul 2021 16:15:24 -0400 Subject: [PATCH 2/3] Include a (timestamp) version in the schedule. Bump it if the content changes --- wafer/schedule/models.py | 34 ++++++++++++++----- .../wafer.schedule/penta_schedule.xml | 2 +- wafer/schedule/views.py | 4 ++- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/wafer/schedule/models.py b/wafer/schedule/models.py index dbfe0671..0a00216b 100644 --- a/wafer/schedule/models.py +++ b/wafer/schedule/models.py @@ -1,5 +1,6 @@ from uuid import UUID +from django.core.cache import cache from django.core.exceptions import ValidationError from django.db import models from django.db.models.signals import post_save, post_delete @@ -360,6 +361,14 @@ def guid(self): return UUID(bytes=hmac.digest()[:16]) +def get_schedule_version(): + """Return the current schedule version""" + version = cache.get('wafer_schedule_version') + if not version: + version = update_schedule_version() + return version + + def invalidate_check_schedule(*args, **kw): sender = kw.pop('sender', None) if sender is Talk or sender is Page: @@ -391,15 +400,24 @@ def update_schedule_items(*args, **kw): item.save(update_fields=['last_updated']) -post_save.connect(invalidate_check_schedule, sender=ScheduleBlock) -post_save.connect(invalidate_check_schedule, sender=Venue) -post_save.connect(invalidate_check_schedule, sender=Slot) -post_save.connect(invalidate_check_schedule, sender=ScheduleItem) +def update_schedule_version(*args, **kwargs): + """Store the schedule version in the Django cache. + + The version is used to allow clients to perform conditional HTTP requests + on the schedule. + + We don't just rely on max(ScheduleItem.updated_at) as that misses + deletions. + """ + version = localtime().isoformat() + cache.set('wafer_schedule_version', version, timeout=None) + return version + -post_delete.connect(invalidate_check_schedule, sender=ScheduleBlock) -post_delete.connect(invalidate_check_schedule, sender=Venue) -post_delete.connect(invalidate_check_schedule, sender=Slot) -post_delete.connect(invalidate_check_schedule, sender=ScheduleItem) +for sender in (ScheduleBlock, Venue, Slot, ScheduleItem): + for receiver in (invalidate_check_schedule, update_schedule_version): + post_save.connect(receiver, sender=sender) + post_delete.connect(receiver, sender=sender) # We also hook up calls from Page and Talk, so # changes to those reflect in the schedule immediately diff --git a/wafer/schedule/templates/wafer.schedule/penta_schedule.xml b/wafer/schedule/templates/wafer.schedule/penta_schedule.xml index f83f3ec8..0ce06b2f 100644 --- a/wafer/schedule/templates/wafer.schedule/penta_schedule.xml +++ b/wafer/schedule/templates/wafer.schedule/penta_schedule.xml @@ -2,7 +2,7 @@ {% load i18n %} - {# FIXME: We have no schedule versions #} + {{ schedule_version }} {{ WAFER_CONFERENCE_NAME }} {% if schedule_pages %} diff --git a/wafer/schedule/views.py b/wafer/schedule/views.py index b4f7044e..1a503d66 100644 --- a/wafer/schedule/views.py +++ b/wafer/schedule/views.py @@ -18,7 +18,8 @@ from rest_framework.permissions import IsAdminUser from wafer import __version__ from wafer.pages.models import Page -from wafer.schedule.models import Venue, Slot, ScheduleBlock, ScheduleItem +from wafer.schedule.models import ( + Venue, Slot, ScheduleBlock, ScheduleItem, get_schedule_version) from wafer.schedule.admin import check_schedule, validate_schedule from wafer.schedule.serializers import ScheduleItemSerializer from wafer.talks.models import ACCEPTED, CANCELLED @@ -181,6 +182,7 @@ def get_context_data(self, **kwargs): if pos < len(blocks) - 1: context['next_block'] = blocks[pos + 1] context['schedule_pages'] = generate_schedule(this_block) + context['schedule_version'] = get_schedule_version() return context From aa80d0b739a62a5d0dbf2d76861f5daacdf7e01c Mon Sep 17 00:00:00 2001 From: Stefano Rivera Date: Fri, 23 Jul 2021 20:33:11 -0400 Subject: [PATCH 3/3] Support conditional requests to Schedule public views --- wafer/schedule/models.py | 2 +- wafer/schedule/tests/test_views.py | 33 ++++++++++++++++++++++++++++-- wafer/schedule/views.py | 24 +++++++++++++++++++--- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/wafer/schedule/models.py b/wafer/schedule/models.py index 0a00216b..da40dc46 100644 --- a/wafer/schedule/models.py +++ b/wafer/schedule/models.py @@ -362,7 +362,7 @@ def guid(self): def get_schedule_version(): - """Return the current schedule version""" + """Return the current schedule version as a string""" version = cache.get('wafer_schedule_version') if not version: version = update_schedule_version() diff --git a/wafer/schedule/tests/test_views.py b/wafer/schedule/tests/test_views.py index 1e65fd2f..6070f043 100644 --- a/wafer/schedule/tests/test_views.py +++ b/wafer/schedule/tests/test_views.py @@ -1,11 +1,11 @@ -import json import datetime as D +import json import os.path from io import BytesIO from xml.etree import ElementTree from django.test import Client, TestCase -from django.utils import timezone +from django.utils import http, timezone import icalendar import lxml.etree @@ -125,6 +125,8 @@ def test_simple_table(self): response = c.get('/schedule/') self.assertTrue(len(tracker.queries) < 60) + self.assertIn('Last-Modified', response) + [day1] = response.context['schedule_pages'] assert len(day1.rows) == 3 @@ -1495,6 +1497,7 @@ def test_pentabarf_view(self): # have the basic details we expect present c = Client() response = c.get('/schedule/pentabarf.xml') + self.assertIn('Last-Modified', response) parsed = ElementTree.XML(response.content) self.assertEqual(parsed.tag, 'schedule') self.assertEqual(parsed[0].tag, 'generator') @@ -1537,6 +1540,7 @@ def test_ics_view(self): # and some of the required details c = Client() response = c.get('/schedule/schedule.ics') + self.assertIn('Last-Modified', response) calendar = icalendar.Calendar.from_ical(response.content) # No major errors self.assertFalse(calendar.is_broken) @@ -1549,6 +1553,31 @@ def test_ics_view(self): # Check that we have the page slug in the ical event self.assertTrue('/test0/' in event['url']) + def test_xml_conditional_requests(self): + # All the public schedule views implement these, but we'll just check + # one of them + c = Client() + response = c.get('/schedule/pentabarf.xml') + self.assertEqual(response.status_code, 200) + last_modified = response['Last-Modified'] + + with QueryTracker() as tracker: + not_modified_response = c.get( + '/schedule/pentabarf.xml', HTTP_IF_MODIFIED_SINCE=last_modified) + self.assertEqual(not_modified_response.status_code, 304) + self.assertLess(len(tracker.queries), 2) + + last_modified_seconds = http.parse_http_date(last_modified) + last_modified_seconds -= 1 + before_last_modified = http.http_date(last_modified_seconds) + + with QueryTracker() as tracker: + modified_response = c.get( + '/schedule/pentabarf.xml', + HTTP_IF_MODIFIED_SINCE=before_last_modified) + self.assertEqual(modified_response.status_code, 200) + self.assertGreater(len(tracker.queries), 10) + class JsonViewTests(TestCase): diff --git a/wafer/schedule/views.py b/wafer/schedule/views.py index 1a503d66..300b31fc 100644 --- a/wafer/schedule/views.py +++ b/wafer/schedule/views.py @@ -5,13 +5,16 @@ from icalendar import Calendar, Event -from django.db.models import Q -from django.views.generic import TemplateView, View +from django.conf import settings from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.db.models import Q from django.http import HttpResponse, JsonResponse from django.utils import timezone -from django.conf import settings +from django.utils.dateparse import parse_datetime +from django.utils.decorators import method_decorator +from django.views.decorators.http import condition +from django.views.generic import TemplateView, View from bakery.views import BuildableDetailView, BuildableTemplateView, BuildableMixin from rest_framework import viewsets @@ -145,6 +148,15 @@ def lookup_highlighted_venue(request): return None +def schedule_version_last_modified(request, **kwargs): + """Return the current schedule version as a datetime""" + version = get_schedule_version() + return parse_datetime(version) + + +@method_decorator( + condition(last_modified_func=schedule_version_last_modified), + name='dispatch') class ScheduleView(BuildableTemplateView): template_name = 'wafer.schedule/full_schedule.html' build_path = 'schedule/index.html' @@ -383,6 +395,9 @@ def get_context_data(self, block_id=None, **kwargs): return context +@method_decorator( + condition(last_modified_func=schedule_version_last_modified), + name='dispatch') class ICalView(View, BuildableMixin): build_path = 'schedule/schedule.ics' @@ -431,6 +446,9 @@ def build(self): self.build_file(path, self.get_content()) +@method_decorator( + condition(last_modified_func=schedule_version_last_modified), + name='dispatch') class JsonDataView(View, BuildableMixin): build_path = "schedule/schedule.json"