From f5e32a6649a63e9391fab8fbf0d0187b55281bc0 Mon Sep 17 00:00:00 2001 From: Vincent Mahnke Date: Sun, 4 Jan 2026 15:03:22 +0100 Subject: [PATCH] feat!: Removes XML endpoint and adds fields for German and English tour details --- README.rst | 6 + pretix_congressschedule/api.py | 264 ++++++----------------------- pretix_congressschedule/signals.py | 71 ++++++-- pretix_congressschedule/urls.py | 7 +- 4 files changed, 119 insertions(+), 229 deletions(-) diff --git a/README.rst b/README.rst index fa06bdc..83bac22 100644 --- a/README.rst +++ b/README.rst @@ -66,6 +66,12 @@ Development setup Changelog --------- +2.0.0 +~~~~~ + +- Removes XML endpoint (breaking) +- Replaces language section with "Hackertours Settings" section containing language of tour and links for English and German tour details + 1.1.1 ~~~~~ diff --git a/pretix_congressschedule/api.py b/pretix_congressschedule/api.py index ebc1ba7..88d3d90 100644 --- a/pretix_congressschedule/api.py +++ b/pretix_congressschedule/api.py @@ -5,219 +5,13 @@ try: from pretix.base.models import SubEventMetaValue except Exception: # pragma: no cover SubEventMetaValue = None -import xml.etree.ElementTree as ET from collections import defaultdict -from datetime import timedelta import uuid import re from . import __version__ import json -class CongressScheduleXMLView(views.APIView): - def get(self, request, organizer, event, *args, **kwargs): - try: - ev = Event.objects.get(organizer__slug=organizer, slug=event) - except Event.DoesNotExist: - return HttpResponse(b'Event not found', status=404, content_type='application/xml') - - if not ev.has_subevents: - return HttpResponse( - b'Event is not an event-series: https://docs.pretix.eu/guides/event-series/?h=dates#how-to-create-an-event-series', - status=400, - content_type='application/xml' - ) - - subs = SubEvent.objects.filter(event=ev).order_by('date_from') - - root = ET.Element('schedule') - - gen = ET.SubElement(root, 'generator') - gen.set('name', 'pretix-congressschedule') - gen.set('version', __version__) - - try: - feed_url = request.build_absolute_uri() - ET.SubElement(root, 'url').text = feed_url - except Exception: - pass - - # Version string – keep simple and stable per event - ET.SubElement(root, 'version').text = f"{ev.slug}-v1" - - conf = ET.SubElement(root, 'conference') - conf_title = ev.name.localize(ev.settings.locale) if hasattr(ev.name, 'localize') else str(ev.name) - ET.SubElement(conf, 'title').text = conf_title or str(ev.slug) - acronym = f"{organizer}_{event}".lower() - ET.SubElement(conf, 'acronym').text = acronym - - # start/end/days based on subevents if available, else fall back to event - all_starts = [se.date_from for se in subs if se.date_from] - all_ends = [se.date_to for se in subs if se.date_to] - - if all_starts: - ET.SubElement(conf, 'start').text = min(all_starts).isoformat() - if all_ends: - ET.SubElement(conf, 'end').text = max(all_ends).isoformat() - - # days count – unique calendar days from subevents - unique_days = sorted({(se.date_from.date() if se.date_from else None) for se in subs} - {None}) - if unique_days: - ET.SubElement(conf, 'days').text = str(len(unique_days)) - - # time zone name – try Event.timezone or settings - tz_name = getattr(ev, 'timezone', None) or getattr(ev.settings, 'timezone', None) - if tz_name: - tz_text = tz_name if isinstance(tz_name, str) else str(tz_name) - ET.SubElement(conf, 'time_zone_name').text = tz_text - - # Group subevents into days and rooms - # days: {date -> {room_name -> [subevents]}} - days: dict = defaultdict(lambda: defaultdict(list)) - - def get_room_name(se): - # Try SubEvent.location if present, else fallback to `Main` - loc = getattr(se, 'location', None) - if hasattr(loc, 'localize'): - try: - txt = loc.localize(ev.settings.locale) - except Exception: - txt = str(loc) - else: - txt = str(loc) if loc else '' - return (txt or 'Main').strip() or 'Main' - - for se in subs: - if not se.date_from: - # Skip entries without a start - continue - day_key = se.date_from.date() - room = get_room_name(se) - days[day_key][room].append(se) - - # Emit elements in chronological order - for day_index, (day_date, rooms) in enumerate(sorted(days.items()), start=1): - # Compute day start/end from all events this day - starts = [se.date_from for r in rooms.values() for se in r if se.date_from] - ends = [se.date_to for r in rooms.values() for se in r if se.date_to] - day_start = min(starts) if starts else None - # If end is missing for any, approximate using +0 duration => start - if ends: - day_end = max(ends) - else: - day_end = (day_start + timedelta(minutes=0)) if day_start else None - - day_el = ET.SubElement(root, 'day') - if day_date: - day_el.set('date', day_date.isoformat()) - if day_start: - day_el.set('start', day_start.isoformat()) - if day_end: - day_el.set('end', day_end.isoformat()) - day_el.set('index', str(day_index)) - - # Emit containers - for room_name, events_in_room in sorted(rooms.items(), key=lambda x: x[0].lower()): - room_el = ET.SubElement(day_el, 'room') - room_el.set('name', room_name) - # Optional guid on room – stable UUID5 based on names - room_el.set('guid', str(uuid.uuid5(uuid.NAMESPACE_DNS, f"room:{organizer}:{event}:{room_name}"))) - - # Emit each in chronological order within the room - for se in sorted(events_in_room, key=lambda s: s.date_from or 0): - ev_el = ET.SubElement(room_el, 'event') - ev_el.set('id', str(se.pk)) - ev_el.set('guid', str(uuid.uuid5(uuid.NAMESPACE_DNS, f"subevent:{ev.pk}:{se.pk}"))) - - # Helper: localize strings - def _localize(val): - if hasattr(val, 'localize'): - try: - return val.localize(ev.settings.locale) - except Exception: - return str(val) - return str(val) if val is not None else '' - - # Required children according to schema - ET.SubElement(ev_el, 'room').text = room_name - title = _localize(se.name) - ET.SubElement(ev_el, 'title').text = title - ET.SubElement(ev_el, 'subtitle').text = '' - ET.SubElement(ev_el, 'type').text = 'subevent' - - # date (full datetime with TZ) - if se.date_from: - ET.SubElement(ev_el, 'date').text = se.date_from.isoformat() - - # start (HH:MM or HH:MM:SS) - if se.date_from: - ET.SubElement(ev_el, 'start').text = se.date_from.strftime('%H:%M') - - # duration from date_to - date_from - dur_txt = '00:00' - if se.date_from and se.date_to and se.date_to >= se.date_from: - delta: timedelta = se.date_to - se.date_from - total_seconds = int(delta.total_seconds()) - hours = total_seconds // 3600 - minutes = (total_seconds % 3600) // 60 - seconds = total_seconds % 60 - # prefer HH:MM if no seconds, else HH:MM:SS - if seconds == 0: - dur_txt = f"{hours:02d}:{minutes:02d}" - else: - dur_txt = f"{hours:02d}:{minutes:02d}:{seconds:02d}" - ET.SubElement(ev_el, 'duration').text = dur_txt - - ET.SubElement(ev_el, 'abstract').text = '' - - # slug (pattern: "[a-z0-9_]{4,}-[a-z0-9\-_]{4,}") - def slugify(text: str) -> str: - text = (text or '').lower() - text = re.sub(r'\s+', '-', text) - text = re.sub(r'[^a-z0-9\-_]', '', text) - text = text.strip('-_') - return text or 'item' - - base = f"{organizer}_{event}".lower() - second = slugify(title) - if len(second) < 4: - second = f"{second}-{se.pk}" - ET.SubElement(ev_el, 'slug').text = f"{base}-{second}" - - # track – use room name as a simple track assignment - ET.SubElement(ev_el, 'track').text = slugify(room_name) or 'general' - - # Optional elements: language – per subevent via SubEventMetaValue - def _get_lang(subevent: SubEvent) -> str: - if SubEventMetaValue is not None: - try: - v = ( - SubEventMetaValue.objects - .filter(subevent=subevent, key='congressschedule_language') - .values_list('value', flat=True) - .first() - ) - return (v or 'deen').strip() or 'deen' - except Exception: - pass - # Fallbacks for environments without pretix DB access - md = getattr(subevent, 'meta_data', None) or {} - if isinstance(md, dict) and 'congressschedule_language' in md: - return (md.get('congressschedule_language') or 'deen') - se_settings = getattr(subevent, 'settings', None) - try: - return se_settings.get('congressschedule_language', 'deen') if se_settings is not None else 'deen' - except Exception: - return 'deen' - - lang = _get_lang(se) - ET.SubElement(ev_el, 'language').text = str(lang or 'deen') - - # Leave optional complex children (persons, recording, links, attachments) empty for now - - xml_bytes = ET.tostring(root, encoding='utf-8', xml_declaration=True) - return HttpResponse(xml_bytes, content_type='application/xml') - class CongressScheduleJSONView(views.APIView): def get(self, request, organizer, event): @@ -313,7 +107,7 @@ class CongressScheduleJSONView(views.APIView): text = text.strip('-_') return text or 'item' - def _get_lang(subevent: SubEvent) -> str: + def _get_language(subevent: SubEvent) -> str: if SubEventMetaValue is not None: try: v = ( @@ -333,6 +127,48 @@ class CongressScheduleJSONView(views.APIView): return se_settings.get('congressschedule_language', 'deen') if se_settings is not None else 'deen' except Exception: return 'deen' + + def _get_websiteDE(subevent: SubEvent) -> str: + if SubEventMetaValue is not None: + try: + v = ( + SubEventMetaValue.objects + .filter(subevent=subevent, key='congressschedule_website_de') + .values_list('value', flat=True) + .first() + ) + return (v or '').strip() + except Exception: + pass + md = getattr(subevent, 'meta_data', None) or {} + if isinstance(md, dict) and 'congressschedule_website_de' in md: + return (md.get('congressschedule_website_de') or '').strip() + se_settings = getattr(subevent, 'settings', None) + try: + return se_settings.get('congressschedule_website_de', '') if se_settings is not None else '' + except Exception: + return '' + + def _get_websiteEN(subevent: SubEvent) -> str: + if SubEventMetaValue is not None: + try: + v = ( + SubEventMetaValue.objects + .filter(subevent=subevent, key='congressschedule_website_en') + .values_list('value', flat=True) + .first() + ) + return (v or '').strip() + except Exception: + pass + md = getattr(subevent, 'meta_data', None) or {} + if isinstance(md, dict) and 'congressschedule_website_en' in md: + return (md.get('congressschedule_website_en') or '').strip() + se_settings = getattr(subevent, 'settings', None) + try: + return se_settings.get('congressschedule_website_en', '') if se_settings is not None else '' + except Exception: + return '' for day_index, (day_date, rooms) in enumerate(sorted(days.items()), start=1): starts = [se.date_from for r in rooms.values() for se in r if se.date_from] @@ -369,7 +205,9 @@ class CongressScheduleJSONView(views.APIView): if len(second) < 4: second = f"{second}-{se.pk}" - lang = _get_lang(se) + language = _get_language(se) + websiteDE = _get_websiteDE(se) + websiteEN = _get_websiteEN(se) ev_obj = { "id": se.pk, @@ -384,13 +222,17 @@ class CongressScheduleJSONView(views.APIView): "subtitle": "", "track": "Hackertours", "type": "Tour", - "language": str(lang or "de, en"), + "language": str(language or "de, en"), "abstract": se.frontpage_text.localize(ev.settings.locale) if hasattr(se.frontpage_text, 'localize') else str(se.frontpage_text) if se.frontpage_text else "", "persons": [], "links": [ { - "url": "TODO", - "title": "TODO", + "url": str(websiteDE), + "title": title + " (DE)", + }, + { + "url": str(websiteEN), + "title": title + " (DE)", } ], } diff --git a/pretix_congressschedule/signals.py b/pretix_congressschedule/signals.py index 24ed1e4..587ae8d 100644 --- a/pretix_congressschedule/signals.py +++ b/pretix_congressschedule/signals.py @@ -7,7 +7,7 @@ except Exception: # pragma: no cover - during docs build or without pretix SubEventMetaValue = None -class SubEventLanguageForm(forms.Form): +class SubEventHackertoursForm(forms.Form): language = forms.ChoiceField( label=_("Language"), required=False, @@ -18,6 +18,16 @@ class SubEventLanguageForm(forms.Form): ('en', _("English")), ], ) + website_en = forms.URLField( + label=_("Website (English)"), + required=False, + help_text=_("Link to the English tour details on https://hackertours.hamburg.ccc.de/en/"), + ) + website_de = forms.URLField( + label=_("Website (German)"), + required=False, + help_text=_("Link to the German tour details on https://hackertours.hamburg.ccc.de/de/"), + ) def __init__(self, *args, **kwargs): self.event = kwargs.pop('event') @@ -25,45 +35,83 @@ class SubEventLanguageForm(forms.Form): super().__init__(*args, **kwargs) # Pre-fill from subevent meta if available if self.subevent: - val = ( + languageValue = ( SubEventMetaValue.objects .filter(subevent=self.subevent, property__name='congressschedule_language') .values_list('value', flat=True) .first() ) - self.fields['language'].initial = val or '' + self.fields['language'].initial = languageValue or '' + websiteENValue = ( + SubEventMetaValue.objects + .filter(subevent=self.subevent, property__name='congressschedule_website_en') + .values_list('value', flat=True) + .first() + ) + self.fields['website_en'].initial = websiteENValue or '' + websiteDEValue = ( + SubEventMetaValue.objects + .filter(subevent=self.subevent, property__name='congressschedule_website_de') + .values_list('value', flat=True) + .first() + ) + self.fields['website_de'].initial = websiteDEValue or '' elif self.subevent and hasattr(self.subevent, 'settings'): # Fallback (older pretix): might be event-wide, keep as last resort self.fields['language'].initial = "deen" + self.fields['website_en'].initial = "" + self.fields['website_de'].initial = "" @property def title(self): - return _("Language") + return _("Hackertours Settings") def save(self): if not self.subevent: return - val = (self.cleaned_data.get('language') or '').strip() or 'deen' + languageValue = (self.cleaned_data.get('language') or '').strip() or 'deen' + websiteENValue = (self.cleaned_data.get('website_en') or '').strip() + websiteDEValue = (self.cleaned_data.get('website_de') or '').strip() # Persist as real subevent meta value so it's scoped per subevent from pretix.base.models import EventMetaProperty - property_obj, _ = EventMetaProperty.objects.get_or_create( + language_property_obj, _ = EventMetaProperty.objects.get_or_create( name='congressschedule_language', defaults={'default': '', 'organizer': self.event.organizer} ) SubEventMetaValue.objects.update_or_create( subevent=self.subevent, - property=property_obj, - defaults={'value': val}, + property=language_property_obj, + defaults={'value': languageValue}, + ) + + website_en_property_obj, _ = EventMetaProperty.objects.get_or_create( + name='congressschedule_website_en', + defaults={'default': '', 'organizer': self.event.organizer} + ) + SubEventMetaValue.objects.update_or_create( + subevent=self.subevent, + property=website_en_property_obj, + defaults={'value': websiteENValue}, + ) + + website_de_property_obj, _ = EventMetaProperty.objects.get_or_create( + name='congressschedule_website_de', + defaults={'default': '', 'organizer': self.event.organizer} + ) + SubEventMetaValue.objects.update_or_create( + subevent=self.subevent, + property=website_de_property_obj, + defaults={'value': websiteDEValue}, ) -def subevent_forms(sender, request, subevent, **kwargs): +def subevent_hackertours_forms(sender, request, subevent, **kwargs): # Provide our additional subevent form import logging logger = logging.getLogger(__name__) logger.debug("Providing congressschedule subevent form for event %s, subevent %s", sender.slug, getattr(subevent, 'name', 'no-subevent')) - form = SubEventLanguageForm( + form = SubEventHackertoursForm( data=request.POST if request.method == 'POST' else None, event=sender, subevent=subevent, @@ -73,11 +121,10 @@ def subevent_forms(sender, request, subevent, **kwargs): def connect_signals(): - # Connect to pretix.control.signals.subevent_forms at import time try: from pretix.control import signals as control_signals - control_signals.subevent_forms.connect(subevent_forms, dispatch_uid='pretix_congressschedule_subevent_language') + control_signals.subevent_forms.connect(subevent_hackertours_forms, dispatch_uid='pretix_congressschedule_subevent_hackertours') except Exception: # Pretix not fully loaded in some contexts (e.g., docs build) pass diff --git a/pretix_congressschedule/urls.py b/pretix_congressschedule/urls.py index d10b71a..8953ec0 100644 --- a/pretix_congressschedule/urls.py +++ b/pretix_congressschedule/urls.py @@ -1,12 +1,7 @@ from django.urls import path -from .api import CongressScheduleXMLView, CongressScheduleJSONView, HackertoursMarkdownView +from .api import CongressScheduleJSONView, HackertoursMarkdownView urlpatterns = [ - path( - 'api/v1/event///schedule.xml', - CongressScheduleXMLView.as_view(), - name='schedule-xml', - ), path( 'api/v1/event///schedule.json', CongressScheduleJSONView.as_view(),