Pretalx Integration

This document covers the architecture of the pretalx-client package, how to regenerate it from the upstream OpenAPI schema, the compatibility guarantees it provides, and the rollout plan for publishing it as a standalone library.

Architecture Overview

The Pretalx integration is organized into three layers, each with a distinct responsibility:

packages/pretalx-client/src/pretalx_client/
+-- generated/          # Layer 1: Codegen output (DO NOT EDIT)
|   +-- models.py       #   datamodel-code-generator output
+-- adapters/           # Layer 2: Runtime quirk handling
|   +-- normalization.py #   Multilingual field resolution, ID-to-name mapping
+-- models.py           # Layer 2: Typed dataclasses (PretalxSpeaker, PretalxTalk, PretalxSlot)
+-- client.py           # Layer 3: Stable public API (PretalxClient)
+-- __init__.py          # Public re-exports

Layer 1: Generated Models

Output of datamodel-code-generator run against the Pretalx OpenAPI 3.0.3 schema. Located in packages/pretalx-client/src/pretalx_client/generated/. These files are machine-generated and must never be edited by hand – they are overwritten on every schema sync.

Layer 2: Adapters and Typed Models

Handwritten code that compensates for runtime behavior not captured in the OpenAPI schema:

  • models.py – Frozen dataclasses (PretalxSpeaker, PretalxTalk, PretalxSlot) with from_api() class methods that normalize raw API dicts into well-typed Python objects. Handles multilingual field resolution, ID-to-name mapping, and datetime parsing.

  • adapters/normalization.py – Standalone helpers (localized(), resolve_id_or_localized()) for resolving Pretalx’s multilingual and integer-ID field patterns.

These files are maintained manually and are the primary place to handle new API quirks as they are discovered.

Layer 3: Client Facade

PretalxClient in client.py is the stable public API. It provides methods like fetch_speakers(), fetch_talks(), and fetch_schedule() that return typed dataclasses. Consumers (including the Django sync service) import from here and should not depend on generated code or adapter internals directly.

Django Integration

src/django_program/pretalx/sync.py contains PretalxSyncService, which consumes PretalxClient and maps the typed dataclasses into Django ORM models (Speaker, Talk, ScheduleSlot, Room). The re-export shim at src/django_program/pretalx/client.py bridges the workspace package into the Django app’s import namespace.

Overrides

Pretalx is the source of truth for your schedule, but the real world does not care about your source of truth. Speakers cancel at the last minute. Rooms get renamed. A talk moves from Hall A to Hall B because of a fire alarm. The override system lets organizers patch any synced field locally without touching the upstream Pretalx record, and those patches survive re-syncs.

How Overrides Work

Each synced model (Talk, Speaker, Room) has a matching one-to-one override model (TalkOverride, SpeakerOverride, RoomOverride). Override fields default to blank/null. When a field is populated, it takes priority over the synced value. When blank, the original synced value shows through.

The synced models expose effective_* properties that handle this resolution:

talk = Talk.objects.get(pretalx_code="ABCDEF")

# Returns the synced title unless a TalkOverride sets override_title
talk.effective_title

# Returns "cancelled" if is_cancelled=True on the override,
# otherwise override_state if set, otherwise the synced state
talk.effective_state

Use effective_* properties in templates and views. Use the bare fields (talk.title, talk.state) only when you need the raw synced value.

Override Models

TalkOverride

One-to-one with Talk. Overridable fields:

Field

Type

Effect

override_title

CharField

Replaces talk.effective_title

override_abstract

TextField

Replaces talk.effective_abstract

override_state

CharField

Replaces talk.effective_state

override_room

ForeignKey

Replaces talk.effective_room

override_slot_start

DateTimeField

Replaces talk.effective_slot_start

override_slot_end

DateTimeField

Replaces talk.effective_slot_end

is_cancelled

BooleanField

Forces talk.effective_state to "cancelled"

note

TextField

Internal-only note (not displayed to attendees)

The is_cancelled flag takes priority over override_state. If both are set, the talk shows as cancelled.

SpeakerOverride

One-to-one with Speaker. Overridable fields:

Field

Type

Effect

override_name

CharField

Replaces speaker.effective_name

override_biography

TextField

Replaces speaker.effective_biography

override_avatar_url

URLField

Replaces speaker.effective_avatar_url

override_email

EmailField

Replaces speaker.effective_email

note

TextField

Internal-only note

RoomOverride

One-to-one with Room. Overridable fields:

Field

Type

Effect

override_name

CharField

Replaces room.effective_name

override_description

TextField

Replaces room.effective_description

override_capacity

PositiveIntegerField

Replaces room.effective_capacity

note

TextField

Internal-only note

SubmissionTypeDefault

SubmissionTypeDefault is not an override in the same sense. It provides fallback room and time-slot values for talks of a given Pretalx submission type (e.g. “Poster”, “Tutorial”) that arrive from Pretalx without scheduling data.

Field

Type

Purpose

submission_type

CharField

The Pretalx type name to match (case-sensitive)

default_room

ForeignKey

Room assigned to unscheduled talks of this type

default_date

DateField

Date for synthesized slot times

default_start_time

TimeField

Start time combined with default_date

default_end_time

TimeField

End time combined with default_date

Type defaults are applied automatically at the end of sync_all() via apply_type_defaults(). They only affect talks where room is None, so talks already assigned a room by Pretalx are left untouched.

Creating Overrides via Django Admin

All override models are registered in the Django admin. Navigate to Pretalx > Talk Overrides (or Speaker Overrides, Room Overrides) to create or edit overrides.

Cancel a talk

  1. Go to Pretalx > Talk Overrides > Add.

  2. Select the talk from the talk field.

  3. Check is_cancelled.

  4. Add a note explaining why (e.g. “Speaker flight cancelled”).

  5. Save. The talk’s effective_state now returns "cancelled".

Move a talk to a different room

  1. Go to Pretalx > Talk Overrides > Add.

  2. Select the talk.

  3. Set override_room to the new room.

  4. Optionally adjust override_slot_start and override_slot_end if the time also changed.

  5. Save.

Rename a room on-site

  1. Go to Pretalx > Room Overrides > Add.

  2. Select the room.

  3. Set override_name to the correct name (e.g. “Hall B” instead of “Ballroom 2”).

  4. Save. All schedule displays using room.effective_name now show the new name.

Set up defaults for poster sessions

  1. Go to Pretalx > Submission Type Defaults > Add.

  2. Set submission_type to "Poster" (must match the Pretalx type exactly).

  3. Set default_room to the poster hall.

  4. Set default_date, default_start_time, and default_end_time for the poster session window.

  5. Save. On the next sync, any poster talks arriving without scheduling data get assigned to this room and time slot.

Overrides and Sync

Overrides are stored in separate database tables from the synced data. When sync_all() runs, it updates the Talk, Speaker, and Room tables with fresh data from Pretalx. The override tables are not touched. This means:

  • Overrides persist across syncs. A TalkOverride created before a sync is still there after the sync completes.

  • The synced (base) fields get updated to match Pretalx. If Pretalx changes a talk’s title, the Talk.title field updates, but TalkOverride.override_title stays as-is. The effective_title property still returns the override value.

  • Override resolution happens at read time via the effective_* properties, not during sync. There is no merge step.

  • Deleting an override restores the synced values instantly. The base data was never modified.

The conference field on each override is validated against the parent entity’s conference. The admin and model validation both reject overrides that reference entities from a different conference.

The is_empty Property

Each override model has an is_empty property that returns True when no override fields carry a value (only the note field is populated, or everything is blank). Use this to identify overrides that were created but never filled in:

stale = TalkOverride.objects.all()
for override in stale:
    if override.is_empty:
        override.delete()

Schema Regeneration

Prerequisites

  • uv installed and project dependencies synced (uv sync --all-groups)

  • Internet access (fetches from https://docs.pretalx.org/schema.yml)

Running the Pipeline

make pretalx-sync-schema

This is an alias for make pretalx-codegen, which chains three steps:

  1. Validate – Runs scripts/pretalx/validate_schema.py to verify the OpenAPI schema integrity (required top-level keys, structure checks).

  2. Generate models – Runs scripts/pretalx/generate_client.py against the schema and writes Python dataclass models to packages/pretalx-client/src/pretalx_client/generated/models.py.

  3. Generate HTTP client (make pretalx-generate-http-client) – Runs scripts/pretalx/generate_http_client.py to produce the typed HTTP client.

Automated Schema Drift Detection

A GitHub Actions workflow (.github/workflows/pretalx-schema-sync.yml) runs weekly on Monday at 06:00 UTC. It executes the full make pretalx-sync-schema pipeline and, if any files changed, opens a pull request on the chore/pretalx-schema-sync branch. The workflow can also be triggered manually via workflow_dispatch.

After Regeneration

If the generated models changed:

  1. Run make ci to verify lint, formatting, type-checks, and tests pass.

  2. Review the diff in generated/models.py for any breaking changes to field names or types that would affect the adapter layer.

  3. Update models.py and client.py if new fields need to be surfaced through the typed dataclasses.

  4. Update sync.py on the Django side if the domain model mapping needs to change.

Compatibility Guarantees

What is Stable

The client facade (PretalxClient and the typed dataclasses exported from pretalx_client) provides a stable API. Consumers can depend on:

  • Method signatures: fetch_speakers(), fetch_talks(), fetch_schedule(), fetch_rooms(), fetch_submissions(), fetch_events()

  • Return types: list[PretalxSpeaker], list[PretalxTalk], list[PretalxSlot]

  • Dataclass field names and types on PretalxSpeaker, PretalxTalk, PretalxSlot

What May Change

  • Generated code (generated/) is regenerated from upstream and may change at any time. Do not import from pretalx_client.generated directly.

  • Adapter internals (adapters/) are implementation details. The public helpers localized() and resolve_id_or_localized() are available but not part of the semver contract.

What the Adapters Handle

The adapters exist because the real Pretalx API behaves differently from what the OpenAPI schema documents. These runtime quirks are not bugs to report upstream – they are intentional behavior that varies by Pretalx instance configuration and event setup.

Known Non-Schema Runtime Quirks

The following behaviors are observed in production Pretalx instances but are not documented in the OpenAPI schema. The adapter layer handles all of them transparently.

Multilingual Fields Return Variable Types

Pretalx localized fields (name, title, abstract, etc.) return either a plain str or a dict[str, str] keyed by language code, depending on the instance’s Accept-Language header handling and localization configuration.

# When the instance has a single language:
{"title": "My Talk"}

# When the instance has multiple languages:
{"title": {"en": "My Talk", "de": "Mein Vortrag"}}

The _localized() helper resolves both forms, preferring the en key when available.

ID Fields vs. Inline Objects

The submission_type, track, and room fields on submissions may be returned as integer IDs or as localized inline objects, depending on whether the request is authenticated and which API version the instance runs.

# Integer ID form (common with API tokens):
{"submission_type": 42, "track": 7, "room": 3}

# Inline object form (common with public/unauthenticated access):
{"submission_type": {"en": "Talk"}, "track": {"en": "Web"}, "room": {"en": "Main Hall"}}

The _resolve_id_or_localized() helper handles both. When IDs are returned, the client pre-fetches /submission-types/, /tracks/, and /rooms/ endpoints to build lookup tables.

/talks/ Endpoint Returns 404

Some Pretalx events (notably PyCon US) do not expose the /talks/ endpoint at all, returning HTTP 404. The client falls back to /submissions/?state=confirmed combined with /submissions/?state=accepted to capture all scheduled content including tutorials and sponsor workshops.

# The client handles this automatically:
talks = client.fetch_talks()  # tries /talks/, falls back to /submissions/

/slots/ Uses Different Field Names

The paginated /slots/ endpoint returns slot objects with different keys than the legacy /schedules/latest/ response:

  • Uses submission instead of code to reference the linked talk

  • Does not include a title field (the title must be looked up from the talk)

  • Returns room as an integer ID rather than a name string

The PretalxSlot.from_api() method handles both formats.

Speaker Avatar Field Name Varies

Different Pretalx instances use either avatar_url or avatar for the speaker profile image URL. PretalxSpeaker.from_api() checks both keys.

Rollout Checklist

Versioning

The pretalx-client package follows semver:

Change Type

Version Bump

Example

Generated code regeneration (no adapter changes)

Patch

0.1.0 -> 0.1.1

New adapter/facade features (backward-compatible)

Minor

0.1.1 -> 0.2.0

Breaking changes to facade API

Major

0.2.0 -> 1.0.0

Generated code changes alone are patch bumps because the facade API remains stable. If a schema change requires adapter updates that alter the public dataclass fields, that is a minor or major bump depending on backward compatibility.

Release Process

The package will be published to PyPI from its own repository at github.com/JacobCoffee/pretalx-client. The release flow:

  1. Bump the version in packages/pretalx-client/pyproject.toml:

    cd packages/pretalx-client
    uv version --bump patch  # or minor/major
    
  2. Run make ci to verify everything passes.

  3. Commit and tag:

    git add packages/pretalx-client/pyproject.toml
    git commit -m "chore: bump pretalx-client to X.Y.Z"
    git tag pretalx-client-vX.Y.Z
    git push origin main --tags
    
  4. The tag triggers a publish workflow that builds and uploads to PyPI.

Migration from In-Tree Client

Currently, src/django_program/pretalx/client.py is a re-export shim that imports everything from the pretalx_client workspace package and re-exports it under the django_program.pretalx.client namespace:

# src/django_program/pretalx/client.py (current shim)
from pretalx_client.client import PretalxClient
from pretalx_client.models import PretalxSpeaker, PretalxTalk, PretalxSlot, ...

This allows existing code in sync.py and elsewhere to continue importing from django_program.pretalx.client without changes.

Migration path:

  1. Now (workspace phase): The shim exists. All Django-side code imports from django_program.pretalx.client. The pretalx-client package is resolved via [tool.uv.sources] as a workspace member.

  2. When published to PyPI: Remove the [tool.uv.sources] workspace override so uv resolves pretalx-client from PyPI instead. The shim continues to work – no code changes needed in consumers.

  3. Eventually: Update imports in sync.py and other Django code to import directly from pretalx_client:

    # Before:
    from django_program.pretalx.client import PretalxClient
    # After:
    from pretalx_client import PretalxClient
    

    Then remove the shim file entirely.

uv Sources Strategy

The pyproject.toml at the project root uses [tool.uv.sources] to bridge development and production:

# During development: resolve from workspace
[tool.uv.sources]
pretalx-client = { workspace = true }

[tool.uv.workspace]
members = ["packages/*"]
# In production (after PyPI publish): remove the sources override
# uv will resolve pretalx-client from PyPI using the version in [project.dependencies]
[project]
dependencies = [
    "pretalx-client>=0.1.0",
]

The workspace member at packages/pretalx-client/ has its own pyproject.toml with independent versioning. During development, uv sync links it as an editable install. In CI and production, once the sources override is removed, uv fetches the published wheel from PyPI.