Skip to content

Commit

Permalink
Merge pull request #613 from CTPUG/schedule-caching
Browse files Browse the repository at this point in the history
Support client-side caching of schedules
  • Loading branch information
stefanor committed Jul 24, 2021
2 parents 02cd216 + aa80d0b commit b878758
Show file tree
Hide file tree
Showing 4 changed files with 83 additions and 16 deletions.
36 changes: 27 additions & 9 deletions wafer/schedule/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -360,6 +361,14 @@ def guid(self):
return UUID(bytes=hmac.digest()[:16])


def get_schedule_version():
"""Return the current schedule version as a string"""
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:
Expand All @@ -381,7 +390,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():
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion wafer/schedule/templates/wafer.schedule/penta_schedule.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
{% load i18n %}
<schedule>
<generator name="wafer" version="{{ wafer_version }}" />
<version>{# FIXME: We have no schedule versions #}</version>
<version>{{ schedule_version }}</version>
<conference>
<title>{{ WAFER_CONFERENCE_NAME }}</title>
{% if schedule_pages %}
Expand Down
33 changes: 31 additions & 2 deletions wafer/schedule/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand All @@ -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):

Expand Down
28 changes: 24 additions & 4 deletions wafer/schedule/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,24 @@

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
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
Expand Down Expand Up @@ -144,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'
Expand Down Expand Up @@ -181,6 +194,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


Expand Down Expand Up @@ -381,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'

Expand Down Expand Up @@ -429,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"

Expand Down

0 comments on commit b878758

Please sign in to comment.