From 449f9395086775694f023edbd2d40fd03a81f810 Mon Sep 17 00:00:00 2001
From: Helge Jung <hej@c3pb.de>
Date: Thu, 27 Jul 2023 11:36:39 +0200
Subject: [PATCH] core: generalize CachedSchedule into ConferenceExportCache

---
 src/api/tests/schedule.py                     |  14 +-
 src/api/views/schedule.py                     |  56 ++------
 src/backoffice/views/assemblies.py            |  33 ++++-
 src/backoffice/views/events.py                |  10 +-
 src/core/management/commands/housekeeping.py  |  12 +-
 .../migrations/0098_conferenceexportcache.py  |  34 +++++
 src/core/models/__init__.py                   |   7 +-
 src/core/models/conference.py                 | 131 ++++++++++++++++++
 src/core/models/schedules.py                  |  46 ------
 src/core/tests/__init__.py                    |   1 +
 src/core/tests/exportcache.py                 |  59 ++++++++
 src/core/tests/schedules.py                   |  39 +-----
 12 files changed, 291 insertions(+), 151 deletions(-)
 create mode 100644 src/core/migrations/0098_conferenceexportcache.py
 create mode 100644 src/core/tests/exportcache.py

diff --git a/src/api/tests/schedule.py b/src/api/tests/schedule.py
index b2854ac00..07041e3be 100644
--- a/src/api/tests/schedule.py
+++ b/src/api/tests/schedule.py
@@ -6,7 +6,7 @@ from django.urls import reverse
 from django.utils.timezone import now
 from rest_framework.authtoken.models import Token
 
-from core.models import Assembly, CachedSchedule, Conference, Event, PlatformUser, Room
+from core.models import Assembly, ConferenceExportCache, Conference, Event, PlatformUser, Room
 
 
 class ScheduleTest(TestCase):
@@ -191,7 +191,7 @@ class ScheduleTest(TestCase):
         event.save()
 
         # sanity check: we don't have a cached schedule
-        self.assertFalse(CachedSchedule.objects.all().exists())
+        self.assertFalse(ConferenceExportCache.objects.filter(type=ConferenceExportCache.Type.SCHEDULE).exists())
 
         # query schedule and check that an ETag got sent
         url = reverse('api:conference-schedule', kwargs={'conference': self.conf.slug})
@@ -200,9 +200,9 @@ class ScheduleTest(TestCase):
         self.assertIn('ETag', resp.headers)
         self.assertNotEqual(resp.headers['ETag'], '')
 
-        # check the CachedSchedule
-        self.assertEqual(1, CachedSchedule.objects.count())
-        c = CachedSchedule.objects.first()
+        # check the cached schedule
+        self.assertEqual(1, ConferenceExportCache.objects.filter(type=ConferenceExportCache.Type.SCHEDULE).count())
+        c = ConferenceExportCache.objects.filter(type=ConferenceExportCache.Type.SCHEDULE).first()
         self.assertEqual(self.conf, c.conference)
         self.assertEqual(resp.headers['ETag'], c.etag)
 
@@ -217,8 +217,8 @@ class ScheduleTest(TestCase):
         self.assertEqual(200, resp3.status_code)
         self.assertNotEqual(resp.headers['ETag'], resp3.headers['ETag'])
 
-        # check the CachedSchedule again, should still be only one
-        self.assertEqual(1, CachedSchedule.objects.count())
+        # check the cached schedules again, should still be only one
+        self.assertEqual(1, ConferenceExportCache.objects.filter(type=ConferenceExportCache.Type.SCHEDULE).count())
         c.refresh_from_db()
         self.assertEqual(self.conf, c.conference)
         self.assertEqual(resp3.headers['ETag'], c.etag)
diff --git a/src/api/views/schedule.py b/src/api/views/schedule.py
index 186165591..95f5da7cd 100644
--- a/src/api/views/schedule.py
+++ b/src/api/views/schedule.py
@@ -1,9 +1,7 @@
-from datetime import datetime
 import logging
 
 from django.db.models import F
-from django.http import JsonResponse, HttpResponse, HttpResponseNotModified
-from django.utils.timezone import now
+from django.http import JsonResponse, HttpResponse
 from django.utils.dateparse import parse_datetime
 from django.views.generic import View
 from rest_framework import authentication
@@ -12,12 +10,10 @@ from rest_framework.views import APIView
 import pytz
 
 from core.models.assemblies import Assembly
-from core.models.conference import ConferenceTrack
+from core.models.conference import ConferenceExportCache, ConferenceTrack
 from core.models.events import Event
 from core.models.rooms import Room
-from core.models.schedules import CachedSchedule
 from core.schedules.base import filter_additional_data, schedule_time_to_timedelta
-from core.utils import int_to_custom_string
 
 from ..permissions import IsApiUserOrReadOnly
 from ..schedule import Schedule, ScheduleEncoder
@@ -27,50 +23,28 @@ logger = logging.getLogger(__name__)
 
 
 class BaseScheduleView(ConferenceSlugMixin, View):
-    def prepare_response(self):
+    def get(self, *args, **kwargs):
         req_format = 'json' if self.request.resolver_match.kwargs.get('format') == 'json' else 'xml'
         cache_id = self.get_cache_id() + '.' + req_format
-        cache_entry, _ = CachedSchedule.objects.get_or_create(conference=self.conference, tag=cache_id, defaults={'needs_regeneration': True})
 
-        if cache_entry.is_dirty:
+        def gen_data():
             schedule = Schedule(self.conference)
             events = self.get_events()
             schedule.add_events(events)
 
             if req_format == 'json':
-                cache_entry.data = schedule.json()
+                return schedule.json()
             else:
-                cache_entry.data = schedule.xml()
-            cache_entry.last_generated = now()
-            cache_entry.needs_regeneration = False
-            cache_entry.etag = int_to_custom_string(int(cache_entry.last_generated.timestamp() * 1000))
-            cache_entry.save()
-
-        headers = {
-            'Content-Type': 'application/json' if req_format == 'json' else 'text/xml',
-            'Content-Encoding': 'utf-8',
-            'ETag': cache_entry.etag,
-            'Last-Modified': cache_entry.last_generated.isoformat(),
-        }
-        return headers, cache_entry.data.encode('utf-8')
-
-    def get(self, *args, **kwargs):
-        headers, content = self.prepare_response()
-
-        if 'If-None-Match' in self.request.headers:
-            if self.request.headers['If-None-Match'] == headers['ETag']:
-                return HttpResponseNotModified()
-        if 'If-Modified-Since' in self.request.headers:
-            try:
-                ts_req = datetime.fromisoformat(self.request.headers['If-Modified-Since'])
-                ts_resp = datetime.fromisoformat(headers['Last-Modified'])
-                if ts_req >= ts_resp:
-                    return HttpResponseNotModified()
-            except ValueError:
-                # ignore timestamp parsing error, handle as non-match
-                pass
-
-        return HttpResponse(content, headers=headers)
+                return schedule.xml()
+
+        return ConferenceExportCache.handle_http_request(
+            request=self.request,
+            conference=self.conference,
+            type=ConferenceExportCache.Type.SCHEDULE,
+            ident=cache_id,
+            content_type=lambda: 'application/json' if req_format == 'json' else 'text/xml',
+            result_func=gen_data,
+        )
 
     def get_cache_id(self):
         raise NotImplementedError('Overwrite .get_cache_id() in a descendant class!')
diff --git a/src/backoffice/views/assemblies.py b/src/backoffice/views/assemblies.py
index 6ab153578..01a907465 100644
--- a/src/backoffice/views/assemblies.py
+++ b/src/backoffice/views/assemblies.py
@@ -18,9 +18,9 @@ from rest_framework.authtoken.models import Token
 
 from core.models.assemblies import Assembly, AssemblyMember, AssemblyLink
 from core.models.badges import Badge, BadgeToken
+from core.models.conference import ConferenceExportCache
 from core.models.events import Event
 from core.models.rooms import Room, RoomLink
-from core.models.schedules import CachedSchedule
 from core.models.sso import Application
 from core.models.tags import ConferenceTag
 from core.models.users import PlatformUser
@@ -65,7 +65,11 @@ class CreateAssemblyView(ConferenceMixin, CreateView):
 
         form.instance.save()
 
+        # log the action
+        messages.success(self.request, _('assembly__created'))
         logger.info('Assembly "{name}" ({id}) created by {user}.'.format(name=form.instance.name, id=form.instance.pk, user=self.request.user.username))
+
+        # add current user as first member of the new assembly (so that editing is possible)
         form.instance.members.create(
             member=self.request.user,
             is_representative=True,
@@ -73,8 +77,10 @@ class CreateAssemblyView(ConferenceMixin, CreateView):
             show_public=True,
         )
 
-        messages.success(self.request, _('assembly__created'))
+        # invalidate export caches where necessary
+        ConferenceExportCache.signal_assembly_modification(conference=self.conference, assembly=form.instance)
 
+        # we're done saving the initial version, redirect to edit view
         return redirect(reverse('backoffice:assembly-edit', kwargs={'pk': form.instance.id}))
 
 
@@ -285,11 +291,17 @@ class EditAssemblyView(AssemblyMixin, UpdateView):
             )
         assembly.save()
 
+        # log the action
         messages.success(self.request, _('assemblyedit__success'))
         logger.info(
             'Assembly "%s" (%s) edited by <%s>: %s',
             assembly.slug, assembly.pk, self.request.user.username, changes,
         )
+
+        # invalidate export caches where necessary
+        ConferenceExportCache.signal_assembly_modification(conference=self.conference, assembly=assembly)
+
+        # we're done saving, redirect to edit view again
         return redirect('backoffice:assembly-edit', pk=assembly.id)
 
     def form_invalid(self, form):
@@ -329,6 +341,7 @@ class AssemblyEditChildrenView(AssemblyMixin, View):
         request = self.request
         assembly = self.get_object()
 
+        changed = False
         add_id = request.POST.get('add', None)
         remove_id = request.POST.get('delete', None)
 
@@ -338,6 +351,7 @@ class AssemblyEditChildrenView(AssemblyMixin, View):
                 if child.parent is None:
                     child.parent = assembly
                     child.save()
+                    changed = True
                     messages.success(request, gettext('assemblyedit_addedchild').format(child_name=child.name))
                     logger.info(f'Assembly "{assembly.name}" ({assembly.pk}): added child "{child}" ({child.pk}) upon request by {request.user.username}')
                 else:
@@ -350,12 +364,18 @@ class AssemblyEditChildrenView(AssemblyMixin, View):
                 if child.parent == assembly:
                     child.parent = None
                     child.save()
+                    changed = True
                     messages.success(request, gettext('assemblyedit_removedchild').format(child_name=child.name))
                     logger.info(f'Assembly "{assembly.name}" ({assembly.pk}): removed child "{child}" ({child.pk}) upon request by {request.user.username}')
                 else:
                     messages.warning(request, gettext('assemblyedit_not_removing_foreign_child').format(child_name=child.name))
                     logger.warning(f'Assembly "{assembly.name}" ({assembly.pk}): could not remove child "{child}" ({child.pk}) for {request.user.username} (not mine)')  # noqa: E501
 
+        # invalidate export caches where necessary
+        if changed:
+            ConferenceExportCache.signal_assembly_modification(conference=self.conference, assembly=None)
+
+        # we're done saving, redirect to children list view again
         return redirect('backoffice:assembly-editchildren', pk=assembly.pk)
 
     def get(self, *args, **kwargs):
@@ -403,9 +423,14 @@ class AssemblyEditHierarchyView(AssemblyMixin, View):
         assembly.hierarchy = value
         assembly.save(update_fields=['hierarchy'])
 
+        # log the action
         messages.success(request, gettext('assemblyedit_changedhierarchy'))
         logger.info(f'Assembly "{assembly.slug}" ({assembly.pk}): set hierarchy to "{value}" upon request by <{request.user.username}>')
 
+        # invalidate export caches where necessary
+        ConferenceExportCache.signal_assembly_modification(conference=self.conference, assembly=assembly)
+
+        # we're done saving, redirect to edit view again
         return redirect('backoffice:assembly-edit', pk=assembly.pk)
 
     def get(self, *args, **kwargs):
@@ -562,7 +587,7 @@ class AssemblyRoomView(AssemblyMixin, UpdateView):
 
         logger.info(f'changed room "{form.instance}" on assembly {self.assembly} by {self.request.user}')
         messages.success(self.request, _('updated'))
-        CachedSchedule.signal_schedule_modification(self.conference, obj=self)
+        ConferenceExportCache.signal_schedule_modification(self.conference, obj=self)
 
         return res
 
@@ -1103,7 +1128,7 @@ class RemoveRoomView(AssemblyMixin, View):
 
         room.delete()
         messages.success(self.request, format_lazy('{x}: {name}', x=_('removed'), name=room.name))
-        CachedSchedule.signal_schedule_modification(self.conference, obj=room)
+        ConferenceExportCache.signal_schedule_modification(self.conference, obj=room)
         return redirect('backoffice:assembly', pk=self.assembly.id)
 
 
diff --git a/src/backoffice/views/events.py b/src/backoffice/views/events.py
index 94313cb92..cfc88e55d 100644
--- a/src/backoffice/views/events.py
+++ b/src/backoffice/views/events.py
@@ -8,9 +8,9 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
+from core.models.conference import ConferenceExportCache
 from core.models.events import Event
 from core.models.rooms import Room
-from core.models.schedules import CachedSchedule
 
 from ..forms import CreateAssemblyEventForm, SelfOrganizedSessionForm
 from .mixins import AssemblyMixin, ConferenceMixin
@@ -50,7 +50,7 @@ class AssemblyEventView(AssemblyMixin, UpdateView):
 
     def form_valid(self, form):
         messages.success(self.request, _('updated'))
-        CachedSchedule.signal_schedule_modification(self.conference)
+        ConferenceExportCache.signal_schedule_modification(self.conference)
         return super().form_valid(form)
 
     def get_success_url(self):
@@ -72,7 +72,7 @@ class AssemblyRemoveEventView(AssemblyMixin, DeleteView):
         result = super().delete(*args, **kwargs)
         messages.success(self.request, _('removed'))
         logger.info(f'Event {self.object} removed by {self.request.user}')
-        CachedSchedule.signal_schedule_modification(self.conference)
+        ConferenceExportCache.signal_schedule_modification(self.conference)
         return result
 
     def get_success_url(self, *args, **kwargs):
@@ -182,7 +182,7 @@ class SosEditView(ConferenceMixin, UpdateView):
         try:
             return super().form_valid(form)
         finally:
-            CachedSchedule.signal_schedule_modification(self.conference)
+            ConferenceExportCache.signal_schedule_modification(self.conference)
 
     def get_success_url(self):
         messages.success(self.request, _("Event--sos-updated"))
@@ -198,7 +198,7 @@ class SosDeleteView(ConferenceMixin, DeleteView):
         result = super().delete(*args, **kwargs)
         messages.success(self.request, _('Event--sos-removed%s' % (self.object.name,)))
         logger.info(f"Event (sos) '{self.object.name}' ({self.object.pk!s}) removed by '{self.request.user.username}' ({self.request.user.pk!s})")
-        CachedSchedule.signal_schedule_modification(self.conference)
+        ConferenceExportCache.signal_schedule_modification(self.conference)
         return result
 
     def get_success_url(self, *args, **kwargs):
diff --git a/src/core/management/commands/housekeeping.py b/src/core/management/commands/housekeeping.py
index 7143ecdee..b9b5c9bf2 100644
--- a/src/core/management/commands/housekeeping.py
+++ b/src/core/management/commands/housekeeping.py
@@ -4,9 +4,9 @@ from django.core.management.base import BaseCommand, CommandError
 from django.db.models import Max
 from django.utils import timezone
 
-from ...models.conference import Conference
+from ...models.conference import Conference, ConferenceExportCache
 from ...models.messages import DirectMessage
-from ...models.schedules import CachedSchedule, ScheduleSource, ScheduleSourceImport
+from ...models.schedules import ScheduleSource, ScheduleSourceImport
 from ...models.voucher import Voucher
 
 
@@ -19,7 +19,7 @@ class Command(BaseCommand):
         self._housekeeping_directmessages()
         self._housekeeping_vouchers()
         self._housekeeping_scheduleimports()
-        self._housekeeping_schedulecaching()
+        self._housekeeping_exportcache()
 
     def _housekeeping_directmessages(self):
         # clear all direct messages which are after their expiry date
@@ -70,7 +70,7 @@ class Command(BaseCommand):
         for k, v in schedule_results.items():
             print('  ', k, ' => ', v, sep='')
 
-    def _housekeeping_schedulecaching(self):
+    def _housekeeping_exportcache(self):
         ref = {}
         for c in Conference.objects.all():
             ts = []
@@ -80,7 +80,7 @@ class Command(BaseCommand):
             ref[c.id] = max([x for x in ts if x is not None], default=None)
 
         changed, total = 0, 0
-        for entry in CachedSchedule.objects.all():
+        for entry in ConferenceExportCache.objects.all():
             total += 1
 
             # entries already marked for regeneration don't need further action
@@ -97,7 +97,7 @@ class Command(BaseCommand):
             entry.save(update_fields=['needs_regeneration'])
             changed += 1
 
-        print('Flagged', changed, 'out of', total, 'cached schedules for regeneration.')
+        print('Flagged', changed, 'out of', total, 'cached exports for regeneration.')
 
     def handle(self, *args, **options):
         # call _do_housekeeping repeatedly (unless --forever is not set)
diff --git a/src/core/migrations/0098_conferenceexportcache.py b/src/core/migrations/0098_conferenceexportcache.py
new file mode 100644
index 000000000..4aaea53b9
--- /dev/null
+++ b/src/core/migrations/0098_conferenceexportcache.py
@@ -0,0 +1,34 @@
+# Generated by Django 4.2.2 on 2023-07-27 08:59
+
+import core.fields
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0097_staticpage_language_null'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ConferenceExportCache',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('type', models.CharField(choices=[('schedule', 'Schedule'), ('map', 'Map'), ('assemblies', 'Assemblies')], max_length=20)),
+                ('ident', models.CharField(max_length=50)),
+                ('etag', models.CharField(editable=False, max_length=100)),
+                ('needs_regeneration', models.BooleanField(default=True)),
+                ('last_generated', models.DateTimeField(blank=True, null=True)),
+                ('data', models.TextField(blank=True, null=True)),
+                ('conference', core.fields.ConferenceReference(help_text='Conference__reference_help', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='core.conference', verbose_name='Conference__reference')),
+            ],
+            options={
+                'unique_together': {('conference', 'ident')},
+            },
+        ),
+        migrations.DeleteModel(
+            name='CachedSchedule',
+        ),
+    ]
diff --git a/src/core/models/__init__.py b/src/core/models/__init__.py
index fd19ca451..54c5ac03f 100644
--- a/src/core/models/__init__.py
+++ b/src/core/models/__init__.py
@@ -1,13 +1,13 @@
 from .assemblies import Assembly, AssemblyLikeCount, AssemblyLink, AssemblyMember
 from .badges import Badge, UserBadge, BadgeToken
-from .conference import Conference, ConferenceMember, ConferenceTrack
+from .conference import Conference, ConferenceExportCache, ConferenceMember, ConferenceTrack
 from .dereferrer import UserDereferrerAllowlist, DereferrerStats
 from .board import BulletinBoardEntry
 from .events import Event, EventAttachment, EventLikeCount, EventParticipant
 from .pages import StaticPage, StaticPageRevision
 from .messages import DirectMessage
 from .rooms import Room, RoomLink
-from .schedules import CachedSchedule, ScheduleSource, ScheduleSourceImport, ScheduleSourceMapping
+from .schedules import ScheduleSource, ScheduleSourceImport, ScheduleSourceMapping
 from .shared import BackendMixin
 from .sso import Application
 from .tags import ConferenceTag, TagItem
@@ -20,8 +20,7 @@ __all__ = [
     'Application',
     'Assembly', 'AssemblyLikeCount', 'AssemblyLink', 'AssemblyMember',
     'BackendMixin', 'Badge', 'BadgeToken', 'BulletinBoardEntry',
-    'CachedSchedule',
-    'Conference', 'ConferenceMember', 'ConferenceMemberTicket', 'ConferenceTag', 'ConferenceTrack',
+    'Conference', 'ConferenceExportCache', 'ConferenceMember', 'ConferenceMemberTicket', 'ConferenceTag', 'ConferenceTrack',
     'DereferrerStats', 'DirectMessage',
     'Event', 'EventAttachment', 'EventLikeCount', 'EventParticipant',
     'PlatformUser',
diff --git a/src/core/models/conference.py b/src/core/models/conference.py
index a59d4a67b..d6e5cf5c7 100644
--- a/src/core/models/conference.py
+++ b/src/core/models/conference.py
@@ -3,15 +3,20 @@ import pytz
 from uuid import uuid4
 from datetime import datetime, time, timedelta
 
+from django.conf import settings
 from django.contrib.auth.models import Group
 from django.contrib.postgres import fields as pg_fields
 from django.core.exceptions import ValidationError
 from django.db import models
+from django.db.models import Max
+from django.http import HttpRequest, HttpResponseNotModified, HttpResponse
 from django.utils import timezone
 from django.utils.functional import cached_property
+from django.utils.timezone import now
 from django.utils.translation import get_language, gettext_lazy as _
 
 from ..fields import ConferenceReference
+from ..utils import int_to_custom_string
 from .users import PlatformUser
 
 
@@ -435,3 +440,129 @@ class ConferenceDay:
         self.index = i + 1
         self.start = datetime.combine(date, time(6, 0)).astimezone(tz)
         self.end = datetime.combine(date + timedelta(days=1), time(4, 0)).astimezone(tz)
+
+
+class ConferenceExportCache(models.Model):
+    class Type(models.TextChoices):
+        SCHEDULE = 'schedule'
+        MAP = 'map'
+        ASSEMBLIES = 'assemblies'
+
+    conference = ConferenceReference(related_name='+')
+    type = models.CharField(max_length=20, choices=Type.choices)
+    ident = models.CharField(max_length=50)
+    etag = models.CharField(max_length=100, editable=False)
+    needs_regeneration = models.BooleanField(default=True)
+    last_generated = models.DateTimeField(blank=True, null=True)
+    data = models.TextField(blank=True, null=True)
+
+    class Meta(object):
+        unique_together = [
+            ('conference', 'ident'),
+        ]
+
+    @property
+    def is_dirty(self):
+        # we're flagged for regeneration already or have no data -> yes, we're dirty
+        if self.needs_regeneration is True or self.data is None or self.last_generated is None:
+            return True
+
+        # check per-type if updates are available
+        if self.type == self.Type.SCHEDULE:
+            from .events import Event
+            from .rooms import Room
+
+            # get latest update timestamp for ConferenceTracks, Events and Rooms
+            last_track = ConferenceTrack.objects.filter(conference=self.conference).aggregate(Max('last_update'))['last_update__max']
+            last_event = Event.objects.filter(conference=self.conference).aggregate(Max('last_update'))['last_update__max']
+            last_room = Room.objects.filter(conference=self.conference).aggregate(Max('last_update'))['last_update__max']
+
+            # if one of those is greater than our last generation, we need to update
+            if (last_track is not None and last_track > self.last_generated) or \
+               (last_event is not None and last_event > self.last_generated) or \
+               (last_room is not None and last_room > self.last_generated):
+                return True
+
+        elif self.type == self.Type.MAP:
+            from .assemblies import Assembly
+            last_assemblies = Assembly.objects.filter(conference=self.conference).aggregate(Max('last_update_staff'))['last_update_staff__max']
+            if last_assemblies is not None and last_assemblies > self.last_generated:
+                return True
+
+        elif self.type == self.Type.ASSEMBLIES:
+            from .assemblies import Assembly
+            last_updates = Assembly.objects.filter(conference=self.conference).aggregate(Max('last_update_assembly'), Max('last_update_staff'))
+            last_assemblies = last_updates['last_update_assembly__max']
+            last_staff = last_updates['last_update_staff__max']
+            if (last_assemblies is not None and last_assemblies > self.last_generated) or \
+               (last_staff is not None and last_staff > self.last_generated):
+                return True
+
+        else:
+            raise NotImplementedError('Unexpected value for ConferenceExportCache.type!')
+
+        # no need to update found yet -> there is none
+        return False
+
+    def save(self, *args, **kwargs):
+        if self.last_generated is not None:
+            self.etag = int_to_custom_string(int(self.last_generated.timestamp() * 1000))
+        return super().save(*args, **kwargs)
+
+    @classmethod
+    def handle_http_request(cls, request: HttpRequest, conference, type: Type, ident: str, content_type, result_func):
+        cache_entry, _ = cls.objects.get_or_create(
+            conference=conference, type=type, ident=ident,
+            defaults={'needs_regeneration': True},
+        )
+
+        if cache_entry.is_dirty or settings.DEBUG:
+            cache_entry.data = result_func()
+            cache_entry.last_generated = now()
+            cache_entry.needs_regeneration = False
+            cache_entry.save()
+
+        headers = {
+            'Content-Type': content_type if isinstance(content_type, str) else content_type(),
+            'Content-Encoding': 'utf-8',
+            'ETag': cache_entry.etag,
+            'Last-Modified': cache_entry.last_generated.isoformat(),
+        }
+
+        content = cache_entry.data.encode('utf-8')
+
+        if 'If-None-Match' in request.headers:
+            if request.headers['If-None-Match'] == headers['ETag']:
+                return HttpResponseNotModified()
+
+        if 'If-Modified-Since' in request.headers:
+            try:
+                ts_req = datetime.fromisoformat(request.headers['If-Modified-Since'])
+                ts_resp = datetime.fromisoformat(headers['Last-Modified'])
+                if ts_req >= ts_resp:
+                    return HttpResponseNotModified()
+            except ValueError:
+                # ignore timestamp parsing error, handle as non-match
+                pass
+
+        return HttpResponse(content, headers=headers)
+
+    @classmethod
+    def signal_schedule_modification(cls, conference, obj=None):
+        """
+        Signals that the conference's schedule got an update and will need to be regenerated.
+        :type conference: Conference
+        :type obj: Assembly|Event|Room
+        """
+        # TODO: check if modified obj is relevant to the cached schedule(s)
+        cls.objects.filter(conference=conference, type=cls.Type.SCHEDULE).update(needs_regeneration=True)
+
+    @classmethod
+    def signal_assembly_modification(cls, conference, assembly=None):
+        """
+        Signals that the conference's assemblies got an update and exports may need to be regenerated.
+        :type conference: Conference
+        :type assembly: Assembly
+        """
+        # TODO: check if modified assembly is relevant to the cached schedule(s)
+        cls.objects.filter(conference=conference, type__in=[cls.Type.ASSEMBLIES, cls.Type.MAP]).update(needs_regeneration=True)
diff --git a/src/core/models/schedules.py b/src/core/models/schedules.py
index 6b4cff71a..24fa4c68a 100644
--- a/src/core/models/schedules.py
+++ b/src/core/models/schedules.py
@@ -4,12 +4,10 @@ from uuid import uuid4, UUID
 
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import models
-from django.db.models import Max
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 
 from .assemblies import Assembly
-from .conference import ConferenceTrack
 from .events import Event, EventAttachment, EventParticipant
 from .rooms import Room
 from ..fields import ConferenceReference
@@ -29,50 +27,6 @@ class LocalObjectAccessViolation(Exception):
     pass
 
 
-class CachedSchedule(models.Model):
-    conference = ConferenceReference(related_name='+')
-    tag = models.CharField(max_length=20)
-    etag = models.CharField(max_length=100)
-    needs_regeneration = models.BooleanField(default=True)
-    last_generated = models.DateTimeField(blank=True, null=True)
-    data = models.TextField(blank=True, null=True)
-
-    class Meta(object):
-        unique_together = [
-            ('conference', 'tag'),
-        ]
-
-    @property
-    def is_dirty(self):
-        # we're flagged for regeneration already or have no data -> yes, we're dirty
-        if self.needs_regeneration is True or self.data is None or self.last_generated is None:
-            return True
-
-        # get latest update timestamp for ConferenceTracks, Events and Rooms
-        last_track = ConferenceTrack.objects.filter(conference=self.conference).aggregate(Max('last_update'))['last_update__max']
-        last_event = Event.objects.filter(conference=self.conference).aggregate(Max('last_update'))['last_update__max']
-        last_room = Room.objects.filter(conference=self.conference).aggregate(Max('last_update'))['last_update__max']
-
-        # if one of those is greater than our last generation, we need to update
-        if (last_track is not None and last_track > self.last_generated) or \
-           (last_event is not None and last_event > self.last_generated) or \
-           (last_room is not None and last_room > self.last_generated):
-            return True
-
-        # no need to update found yet -> there is none
-        return False
-
-    @classmethod
-    def signal_schedule_modification(cls, conference, obj=None):
-        """
-        Signals that the conference's schedule got an update and will need to be regenerated.
-        :type conference: Conference
-        :type obj: Assembly|Event|Room
-        """
-        # TODO: check if modified obj is relevant to the cached schedule(s)
-        cls.objects.filter(conference=conference).update(needs_regeneration=True)
-
-
 class ScheduleSource(models.Model):
     id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
 
diff --git a/src/core/tests/__init__.py b/src/core/tests/__init__.py
index f53369815..3568c620d 100644
--- a/src/core/tests/__init__.py
+++ b/src/core/tests/__init__.py
@@ -1,6 +1,7 @@
 from .badges import *  # noqa: F401, F403
 from .bigbluebutton import *  # noqa: F401, F403
 from .events import *  # noqa: F401, F403
+from .exportcache import *  # noqa: F401, F403
 from .search import *  # noqa: F401, F403
 from .users import *  # noqa: F401, F403
 from .tags import *  # noqa: F401, F403
diff --git a/src/core/tests/exportcache.py b/src/core/tests/exportcache.py
new file mode 100644
index 000000000..9ea5bdb06
--- /dev/null
+++ b/src/core/tests/exportcache.py
@@ -0,0 +1,59 @@
+from datetime import timedelta
+
+from django.utils import timezone
+from django.utils.datetime_safe import datetime
+from django.utils.timezone import now
+from django.test import TestCase, override_settings
+
+from ..models.assemblies import Assembly
+from ..models.conference import Conference, ConferenceExportCache
+
+
+class ScheduleTests(TestCase):
+    def setUp(self):
+        self.conference = Conference(
+            slug='foo',
+            name='Foo Conference',
+            start=datetime(2021, 12, 1, 9, 0, 0, tzinfo=timezone.utc),
+            end=datetime(2021, 12, 31, 15, 00, 00, tzinfo=timezone.utc),
+        )
+        self.conference.save()
+
+        self.assembly = Assembly(conference=self.conference, slug='fnord', name='Fnord Assembly', is_official=True)
+        self.assembly.save()
+
+    @override_settings(SCHEDULES_SUPPORT_FILE_PROTOCOL=True)
+    def test_schedule_cache(self):
+        # there should be no cached items at all
+        self.assertFalse(ConferenceExportCache.objects.filter(type=ConferenceExportCache.Type.SCHEDULE).exists())
+
+        # create an event
+        e = self.conference.events.create(assembly=self.assembly, name='Fnord', is_public=True)
+        e.save()
+
+        # creating the event should not have created a cache entry
+        self.assertFalse(ConferenceExportCache.objects.filter(type=ConferenceExportCache.Type.SCHEDULE).exists())
+
+        # create one manually
+        c = ConferenceExportCache(conference=self.conference, type=ConferenceExportCache.Type.SCHEDULE, tag='')
+        c.save()
+
+        # check default data
+        self.assertTrue(c.needs_regeneration)
+        self.assertTrue(c.is_dirty)
+
+        # simulate successful generation
+        c.needs_regeneration = False
+        c.data = 'FNORD'
+        c.last_generated = now()
+        c.save()
+
+        # this should have reset the 'is_dirty' flag
+        self.assertFalse(c.is_dirty)
+
+        # update the event
+        e.last_update = c.last_generated + timedelta(seconds=1)  # ensure event's last_update is newer
+        e.save()
+
+        # now the flag should react to the event updated after 'last_generation'
+        self.assertTrue(c.is_dirty)
diff --git a/src/core/tests/schedules.py b/src/core/tests/schedules.py
index dd67f2df3..df00ec67a 100644
--- a/src/core/tests/schedules.py
+++ b/src/core/tests/schedules.py
@@ -2,14 +2,13 @@ from datetime import datetime, timedelta, timezone
 import json
 import os
 
-from django.utils.timezone import now
 from django.test import TestCase, override_settings
 
 from ..models import Event
 from ..models.assemblies import Assembly
 from ..models.conference import Conference, ConferenceMember
 from ..models.users import PlatformUser
-from ..models.schedules import ScheduleSource, ScheduleSourceImport, ScheduleSourceMapping, CachedSchedule
+from ..models.schedules import ScheduleSource, ScheduleSourceImport, ScheduleSourceMapping
 from ..schedules.base import BaseScheduleSupport, ScheduleTypeManager, \
     filter_additional_data, schedule_time_to_timedelta
 from ..schedules.schedulejson import ScheduleJSONSupport
@@ -254,42 +253,6 @@ class ScheduleTests(TestCase):
         self.assertEqual(r1.id, str(Event.objects.get(slug='minkorrekt').room_id))
         self.assertEqual(r2.id, str(Event.objects.get(slug='blubb').room_id))
 
-    @override_settings(SCHEDULES_SUPPORT_FILE_PROTOCOL=True)
-    def test_cache(self):
-        # there should be no cached schedules
-        self.assertFalse(CachedSchedule.objects.all().exists())
-
-        # create an event
-        e = self.conference.events.create(assembly=self.assembly, name='Fnord', is_public=True)
-        e.save()
-
-        # creating the event should not have created a cache entry
-        self.assertFalse(CachedSchedule.objects.all().exists())
-
-        # create one manually
-        c = CachedSchedule(conference=self.conference, tag='')
-        c.save()
-
-        # check default data
-        self.assertTrue(c.needs_regeneration)
-        self.assertTrue(c.is_dirty)
-
-        # simulate successful generation
-        c.needs_regeneration = False
-        c.data = 'FNORD'
-        c.last_generated = now()
-        c.save()
-
-        # this should have reset the 'is_dirty' flag
-        self.assertFalse(c.is_dirty)
-
-        # update the event
-        e.last_update = c.last_generated + timedelta(seconds=1)  # ensure event's last_update is newer
-        e.save()
-
-        # now the flag should react to the event updated after 'last_generation'
-        self.assertTrue(c.is_dirty)
-
     @override_settings(SCHEDULES_SUPPORT_FILE_PROTOCOL=True)
     def test_xml(self):
         src = ScheduleSource.objects.create(
-- 
GitLab