Repository: pretalx/pretalx

Issue:
make versioned schedule URLs prettier
The public event schedule can currently be accessed via `/event/schedule?version=bla`. To make versions accessible to the static HTML export the querystring has to go: `/event/schedule/v/bla`.

Open questions:
* What about versions which contain emojis or other non-ascii-characters? (╯°□°）╯︵ ┻━┻


Discussion:

Resolving patch:
diff --git a/src/pretalx/agenda/urls.py b/src/pretalx/agenda/urls.py
--- a/src/pretalx/agenda/urls.py
+++ b/src/pretalx/agenda/urls.py
@@ -4,17 +4,33 @@
 
 from .views import feed, location, schedule, speaker, talk
 
+
+def get_schedule_urls(regex_prefix, name_prefix=""):
+    """
+    given a prefix (e.g. /schedule) generate matching schedule-ruls (e.g. /schedule.json, /schedule/feed.xml, ...)
+    """
+
+    regex_prefix = regex_prefix.rstrip('/')
+
+    return [
+        url(f"{regex_prefix}{regex}", view, name=f"{name_prefix}{name}")
+        for regex, view, name in [
+            ('/$', schedule.ScheduleView.as_view(), 'schedule'),
+            ('.xml$', schedule.FrabXmlView.as_view(), 'frab-xml'),
+            ('.xcal$', schedule.FrabXCalView.as_view(), 'frab-xcal'),
+            ('.json$', schedule.FrabJsonView.as_view(), 'frab-json'),
+            ('.ics$', schedule.ICalView.as_view(), 'ical'),
+            ('/feed.xml$', feed.ScheduleFeed(), 'feed'),
+        ]
+    ]
+
+
 app_name = 'agenda'
 urlpatterns = [
     url(f'^(?P<event>[{SLUG_CHARS}]+)/', include([
-        url('^schedule/$', schedule.ScheduleView.as_view(), name='schedule'),
         url('^schedule/changelog$', schedule.ChangelogView.as_view(), name='schedule.changelog'),
-        url('^schedule.xml$', schedule.FrabXmlView.as_view(), name='frab-xml'),
-        url('^schedule.xcal$', schedule.FrabXCalView.as_view(), name='frab-xcal'),
-        url('^schedule.json$', schedule.FrabJsonView.as_view(), name='frab-json'),
-        url('^schedule.ics$', schedule.ICalView.as_view(), name='ical'),
-        url('^schedule/feed.xml$', feed.ScheduleFeed(), name='feed'),
-
+        *get_schedule_urls('^schedule'),
+        *get_schedule_urls('^schedule/v/(?P<version>.+)', 'versioned-'),
         url('^location/$', location.LocationView.as_view(), name='location'),
         url('^talk/(?P<slug>\w+)/$', talk.TalkView.as_view(), name='talk'),
         url('^talk/(?P<slug>\w+)/feedback/$', talk.FeedbackView.as_view(), name='feedback'),
diff --git a/src/pretalx/agenda/views/schedule.py b/src/pretalx/agenda/views/schedule.py
--- a/src/pretalx/agenda/views/schedule.py
+++ b/src/pretalx/agenda/views/schedule.py
@@ -4,8 +4,12 @@
 import pytz
 import vobject
 from csp.decorators import csp_update
-from django.http import HttpResponse, JsonResponse
+from django.http import (
+    HttpResponse, HttpResponsePermanentRedirect, JsonResponse,
+)
+from django.urls import reverse
 from django.utils.decorators import method_decorator
+from django.utils.functional import cached_property
 from django.utils.timezone import now
 from django.views.generic import TemplateView
 
@@ -17,11 +21,33 @@
 class ScheduleDataView(TemplateView):
     template_name = 'agenda/schedule.html'
 
+    @cached_property
+    def version(self):
+        if 'version' in self.kwargs:
+            return unquote(self.kwargs['version'])
+        else:
+            return None
+
+    def get(self, request, *args, **kwargs):
+        if 'version' in request.GET:
+            if request.resolver_match.url_name.startswith('versioned-'):
+                raise Exception('The schedule version can be supplied in the path or querystring, but not both.')
+
+            kwargs['version'] = request.GET['version']
+            return HttpResponsePermanentRedirect(reverse(
+                f'agenda:versioned-{request.resolver_match.url_name}',
+                args=args, kwargs=kwargs
+            ))
+        else:
+            return super().get(request, *args, **kwargs)
+
     def get_object(self):
-        if self.request.GET.get('version'):
-            version = unquote(self.request.GET.get('version'))
-            return self.request.event.schedules.filter(version=version).first() or self.request.event.current_schedule
-        return self.request.event.current_schedule
+        if self.version:
+            return \
+                self.request.event.schedules.filter(version=self.version).first() or \
+                self.request.event.current_schedule
+        else:
+            return self.request.event.current_schedule
 
     def get_context_data(self, *args, **kwargs):
         ctx = super().get_context_data(*args, **kwargs)
@@ -29,8 +55,8 @@ def get_context_data(self, *args, **kwargs):
         event = self.request.event
         tz = pytz.timezone(self.request.event.timezone)
 
-        if not schedule and self.request.GET.get('version'):
-            ctx['version'] = self.request.GET.get('version')
+        if not schedule and self.version:
+            ctx['version'] = self.version
             ctx['error'] = 'wrong-version'
             return ctx
         elif not schedule:
@@ -76,7 +102,7 @@ def get_permission_object(self):
         return self.request.event
 
     def get_object(self):
-        if self.request.GET.get('version') == 'wip' and self.request.user.has_perm('orga.view_schedule', self.request.event):
+        if self.version == 'wip' and self.request.user.has_perm('orga.view_schedule', self.request.event):
             return self.request.event.wip_schedule
         return super().get_object()
 
diff --git a/src/pretalx/schedule/models/schedule.py b/src/pretalx/schedule/models/schedule.py
--- a/src/pretalx/schedule/models/schedule.py
+++ b/src/pretalx/schedule/models/schedule.py
@@ -36,7 +36,7 @@ class Meta:
         unique_together = (('event', 'version'), )
 
     class urls(EventUrls):
-        public = '{self.event.urls.schedule}?version={self.url_version}'
+        public = '{self.event.urls.schedule}/v/{self.url_version}'
 
     @transaction.atomic
     def freeze(self, name, user=None, notify_speakers=True):


Test patch:
diff --git a/src/tests/conftest.py b/src/tests/conftest.py
--- a/src/tests/conftest.py
+++ b/src/tests/conftest.py
@@ -408,7 +408,7 @@ def availability(event):
 
 @pytest.fixture
 def schedule(event):
-    event.release_schedule('Best Version')
+    event.release_schedule('🍪 Version')
     return event.current_schedule
 
 
diff --git a/src/tests/functional/agenda/test_agenda_schedule.py b/src/tests/functional/agenda/test_agenda_schedule.py
--- a/src/tests/functional/agenda/test_agenda_schedule.py
+++ b/src/tests/functional/agenda/test_agenda_schedule.py
@@ -1,4 +1,5 @@
 import datetime
+from urllib.parse import quote
 
 import pytest
 from django.urls import reverse
@@ -154,3 +155,27 @@ def test_speaker_page(client, event, speaker, slot):
     response = client.get(reverse('agenda:speaker', kwargs={'code': speaker.code, 'event': event.slug}), follow=True)
     assert response.status_code == 200
     assert speaker.profiles.get(event=event).biography in response.content.decode()
+
+
+@pytest.mark.django_db
+def test_schedule_page(client, event, speaker, slot, schedule):
+    response = client.get(event.urls.schedule, follow=True)
+    assert response.status_code == 200
+    assert slot.submission.title in response.content.decode()
+
+
+@pytest.mark.django_db
+def test_versioned_schedule_page(client, event, speaker, slot, schedule):
+    event.wip_schedule.slots.all().delete()
+    event.release_schedule('new schedule')
+
+    response = client.get(event.urls.schedule, follow=True)
+    assert slot.submission.title not in response.content.decode()
+
+    response = client.get(schedule.urls.public, follow=True)
+    assert response.status_code == 200
+    assert slot.submission.title in response.content.decode()
+
+    version = quote(schedule.version)
+    redirected_response = client.get(f'/{event.slug}/schedule?version={version}', follow=True)
+    assert redirected_response._request.path == response._request.path