feat: Initial commit

This commit is contained in:
Vincent Mahnke 2025-11-10 11:30:30 +01:00
commit cfa40c6918
Signed by: ViMaSter
GPG key ID: 6D787326BA7D6469
24 changed files with 669 additions and 0 deletions

62
.gitignore vendored Normal file
View file

@ -0,0 +1,62 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
.ropeproject/
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/
# Translations
*.mo
# Django stuff:
*.log
# Sphinx documentation
docs/_build/
# PyBuilder
target/
#Ipython Notebook
.ipynb_checkpoints

17
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,17 @@
pypi:
image:
name: pretix/ci-image
before_script:
- cat $PYPIRC > ~/.pypirc
- pip install -U pip uv
- uv pip install --system -U wheel setuptools twine build pretix-plugin-build check-manifest
script:
- python -m build
- check-manifest .
- twine check dist/*
- twine upload dist/*
only:
- pypi
artifacts:
paths:
- dist/

37
.update-locales Executable file
View file

@ -0,0 +1,37 @@
#!/bin/sh
COMPONENTS=pretix/pretix-plugin-congressschedule pretix/pretix-plugin-congressschedule-js
DIR=pretix_congressschedule/locale
# Renerates .po files used for translating the plugin
set -e
set -x
# Lock Weblate
for c in $COMPONENTS; do
wlc lock $c;
done
# Push changes from Weblate to GitHub
for c in $COMPONENTS; do
wlc commit $c;
done
# Pull changes from GitHub
git pull --rebase
# Update po files itself
make localegen
# Commit changes
git add $DIR/*/*/*.po
git add $DIR/*.pot
git commit -s -m "Update po files
[CI skip]"
# Push changes
git push
# Unlock Weblate
for c in $COMPONENTS; do
wlc unlock $c;
done

13
LICENSE Normal file
View file

@ -0,0 +1,13 @@
Copyright 2025 Vincent 'ViMaSter' Mahnke
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

3
MANIFEST.in Normal file
View file

@ -0,0 +1,3 @@
recursive-include pretix_congressschedule/static *
recursive-include pretix_congressschedule/templates *
recursive-include pretix_congressschedule/locale *

10
Makefile Normal file
View file

@ -0,0 +1,10 @@
all: localecompile
LNGS:=`find pretix_congressschedule/locale/ -mindepth 1 -maxdepth 1 -type d -printf "-l %f "`
localecompile:
django-admin compilemessages
localegen:
django-admin makemessages --keep-pot -i build -i dist -i "*egg*" $(LNGS)
django-admin makemessages -d djangojs --keep-pot -i build -i dist -i "*egg*" $(LNGS)

46
README.rst Normal file
View file

@ -0,0 +1,46 @@
pretix-congressschedule
=======================
This is a plugin for `pretix`_. It generates a `c3voc-schema`_ compatible `schedule.xml` endpoint for events.
Accessing schedule.xml
----------------------
1. Create an `event-series`_ in pretix; a singular event or non-event shop will not work, as products won't have required start and end times associated with them
2. Visit `/api/v1/event/{organizationSlug}/{eventSlug}/schedule.xml` and replace `{organizationSlug}` and `{eventSlug}` with the respective slugs
3. Receive either a 200 status code with an XML document adhering to `schedule.xml.xsd`_ or a 400 error code with additional information inside `<error>`
Development setup
^^^^^^^^^^^^^^^^^
1. Make sure that you have a working `pretix development setup`_.
2. Clone this repository, eg to ``local/pretix-congressschedule``.
3. Activate the virtual environment you use for pretix development.
4. Execute ``pip install -e .`` within this directory to register this application with pretix's plugin registry.
5. Execute ``make`` within this directory to compile translations.
6. Restart your local pretix server. You can now use the plugin from this repository for your events by enabling it in
the 'plugins' tab in the settings.
License
-------
Copyright 2025 Vincent 'ViMaSter' Mahnke
Released under the terms of the Apache License 2.0
.. _pretix: https://github.com/pretix/pretix
.. _pretix development setup: https://docs.pretix.eu/en/latest/development/setup.html
.. _c3voc-schema: https://c3voc.de/wiki/schedule#schedule_xml
.. _schedule.xml.xsd: https://c3voc.de/schedule/schema.xsd
.. _event-series: https://docs.pretix.eu/guides/event-series/?h=dates#how-to-create-an-event-series

View file

@ -0,0 +1 @@
__version__ = "1.0.0"

View file

@ -0,0 +1,193 @@
from django.http import HttpResponse
from rest_framework import views
from pretix.base.models import Event, SubEvent
import xml.etree.ElementTree as ET
from collections import defaultdict
from datetime import timedelta
import uuid
import re
from . import __version__
class CongressScheduleView(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'<?xml version="1.0"?><error>Event not found</error>', status=404, content_type='application/xml')
if not ev.has_subevents:
return HttpResponse(
b'<?xml version="1.0"?><error>Event is not an event-series: https://docs.pretix.eu/guides/event-series/?h=dates#how-to-create-an-event-series</error>',
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 <day> 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 <room> 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 <event> 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: keep minimal but include language if available
lang = getattr(ev.settings, 'locale', None)
if lang:
ET.SubElement(ev_el, 'language').text = str(lang)
# 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')

View file

@ -0,0 +1,43 @@
from django.apps import AppConfig
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy
from . import __version__
class PassbookApp(AppConfig):
name = "pretix_congressschedule"
verbose_name = "Congress Schedule"
class PretixPluginMeta:
name = gettext_lazy("Congress Schedule")
author = "Vincent Mahnke"
description = gettext_lazy("Provides passbook tickets for pretix")
category = "API"
visible = True
featured = True
version = __version__
compatibility = "pretix>=4.17.0"
def ready(self):
from . import signals # NOQA
@cached_property
def compatibility_errors(self):
import shutil
errs = []
if not shutil.which("openssl"):
errs.append("The OpenSSL binary is not installed or not in the PATH.")
return errs
@cached_property
def compatibility_warnings(self):
errs = []
try:
from PIL import Image # NOQA
except ImportError:
errs.append(
"Pillow is not installed on this system, which is required for converting and scaling images."
)
return errs

View file

@ -0,0 +1,19 @@
# pretix-congressschedule
# Copyright (C) 2025 Vincent 'ViMaSter' Mahnke
# This file is distributed under the same license as the pretix-congressschedule package.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-08-16 15:52+0200\n"
"PO-Revision-Date: 2022-03-21 15:45+0000\n"
"Last-Translator: Vincent 'ViMaSter' Mahnke <pretix-congress@vincent.mahn.ke>\n"
"Language-Team: German <https://translate.pretix.eu/projects/pretix/"
"pretix-plugin-congressschedule/de/>\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.11.2\n"

View file

@ -0,0 +1,19 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-08-16 15:52+0200\n"
"PO-Revision-Date: 2017-05-10 13:48+0200\n"
"Last-Translator: Vincent 'ViMaSter' Mahnke <pretix-congress@vincent.mahn.ke>\n"
"Language-Team: \n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 2.0.1\n"

View file

@ -0,0 +1,19 @@
# pretix-congressschedule
# Copyright (C) 2025 Vincent 'ViMaSter' Mahnke
# This file is distributed under the same license as the pretix-congressschedule package.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-08-16 15:52+0200\n"
"PO-Revision-Date: 2022-03-21 15:45+0000\n"
"Last-Translator: Vincent 'ViMaSter' Mahnke <pretix-congress@vincent.mahn.ke>\n"
"Language-Team: German (informal) <https://translate.pretix.eu/projects/"
"pretix/pretix-plugin-congressschedule/de_Informal/>\n"
"Language: de_Informal\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.11.2\n"

View file

@ -0,0 +1,19 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-08-16 15:52+0200\n"
"PO-Revision-Date: 2017-05-10 13:48+0200\n"
"Last-Translator: Vincent 'ViMaSter' Mahnke <pretix-congress@vincent.mahn.ke>\n"
"Language-Team: \n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 2.0.1\n"

View file

@ -0,0 +1,26 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-08-16 15:52+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: pretix_congressschedule/__init__.py:11
msgid "Congress Schedule"
msgstr ""
#: pretix_congressschedule/__init__.py:13
msgid "Provides passbook tickets for pretix"
msgstr ""

View file

@ -0,0 +1,32 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-08-16 15:52+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: pretix_congressschedule/static/pretix_congressschedule/geosuggest.js:4
msgid "Loading suggested geolocations…"
msgstr ""
#: pretix_congressschedule/static/pretix_congressschedule/geosuggest.js:13
msgid ""
"Click on one of the following suggestions to fill in the coordinates "
"automatically:"
msgstr ""
#: pretix_congressschedule/static/pretix_congressschedule/geosuggest.js:35
msgid "Error while loading suggested geolocations."
msgstr ""

View file

View file

@ -0,0 +1,10 @@
from django.urls import path
from .api import CongressScheduleView
urlpatterns = [
path(
'api/v1/event/<str:organizer>/<str:event>/schedule.xml',
CongressScheduleView.as_view(),
name='schedule-xml',
),
]

10
pretixplugin.toml Normal file
View file

@ -0,0 +1,10 @@
# This file is used by the pretix team internally to coordinate releases of this plugin
[plugin]
package = "pretix-congressschedule"
modules = [ "pretix_congressschedule" ]
marketplace_name = "congressschedule"
pypi = true
repository_servers = { origin = "github.com", gitlab = "code.rami.io" }
tag_targets = [ "origin", "gitlab" ]
branch_targets = [ "origin/master", "gitlab/master", "f:gitlab/pypi" ]

39
pyproject.toml Normal file
View file

@ -0,0 +1,39 @@
[project]
name = "pretix-congressschedule"
dynamic = ["version"]
description = "c3voc schedule-compatible schedule.xml endpoint"
readme = "README.rst"
requires-python = ">=3.9"
license = {file = "LICENSE"}
keywords = ["pretix"]
authors = [
{name = "Vincent Mahnke", email = "pretix-congress@vincent.mahn.ke"},
]
maintainers = [
{name = "Vincent Mahnke", email = "pretix-congress@vincent.mahn.ke"},
]
[project.entry-points."pretix.plugin"]
congressschedule = "pretix_congressschedule:PretixPluginMeta"
[project.entry-points."distutils.commands"]
build = "pretix_plugin_build.build:CustomBuild"
[build-system]
requires = [
"setuptools",
"pretix-plugin-build"
]
[project.urls]
homepage = "https://git.hamburg.ccc.de/ViMaSter/pretix-congressschedule"
[tool.setuptools]
include-package-data = true
[tool.setuptools.dynamic]
version = {attr = "pretix_congressschedule.__version__"}
[tool.setuptools.packages.find]
include = ["pretix*"]
namespaces = false

2
pytest.ini Normal file
View file

@ -0,0 +1,2 @@
[pytest]
DJANGO_SETTINGS_MODULE=pretix.testutils.settings

46
setup.cfg Normal file
View file

@ -0,0 +1,46 @@
[flake8]
ignore = N802,W503,E402
max-line-length = 160
exclude = migrations,.ropeproject,static,_static,build,setup.py
[isort]
combine_as_imports = true
default_section = THIRDPARTY
include_trailing_comma = true
known_third_party = pretix
known_standard_library = typing
multi_line_output = 3
skip = setup.py
use_parentheses = True
force_grid_wrap = 0
line_length = 88
known_first_party = pretix_congressschedule
[tool:pytest]
DJANGO_SETTINGS_MODULE = pretix.testutils.settings
[coverage:run]
source = pretix_adyen
omit = */migrations/*,*/urls.py,*/tests/*
[coverage:report]
exclude_lines =
pragma: no cover
def __str__
der __repr__
if settings.DEBUG
NOQA
NotImplementedError
[check-manifest]
ignore =
.update-locales
.update-locales.sh
.gitlab-ci.yml
.install-hooks.sh
pretixplugin.toml
Makefile
pytest.ini
manage.py
tests/*

3
setup.py Normal file
View file

@ -0,0 +1,3 @@
from setuptools import setup
setup()