Pretalx sync¶
How the project imports talks from Pretalx, when it touches the live data, and how admins review
changes before they go live. The implementation lives under
talks/management/commands/_pretalx/;
this page is the operator's manual. For the bare flag reference of the command itself, see
Management commands.
TL;DR¶
manage.py import_pretalx_talksis the workhorse. It is idempotent: an unchanged Pretalx event is a no-op on the second run (no DB writes, no fresh social cards, just an "unchanged" count in the report).--detect-onlylets the importer run periodically and record diffs without applying them. Admins triage them at/admin/talks/pendingpretalxchange/and click Apply selected when ready.- The "Check Pretalx now" object-tool button in that admin page re-runs the detect-only importer for
DEFAULT_EVENTso admins do not need shell access. - A plain-text email digest goes to
PRETALX_DIGEST_RECIPIENTS(or the admin list) at the end of any detect run that produced or refreshed at least one pending row.
Modes¶
import_pretalx_talks runs in one of these modes depending on the flags you pass:
| Flag | What changes happens to the DB |
|---|---|
| (default) | Full mutation: create/update/delete Talks, Speakers, Rooms |
--dry-run |
Nothing, just logs what would happen |
--detect-only |
Writes PendingPretalxChange rows only; live data untouched |
--no-update |
Skips updates to existing rows; still creates new ones |
--skip-images |
Skips talk-image (social-card) generation |
--force-images |
Regenerates every existing talk's image (see "Image regen" below) |
--detect-only and the mutation modes are mutually exclusive in spirit (the former records intent,
the latter applies it). --skip-images wins when combined with --force-images.
Other flags (--event-slug, --event-name, --pretalx-event-url, --api-token, --max-retries,
--no-avatars, --use-cache, --image-format) are covered in
Management commands and via --help.
Change detection¶
The importer compares each Pretalx submission against the local Talk it maps to:
- If every field already matches, the row is reported as unchanged. Nothing is written,
updated_atdoes not bump, and the social card is not regenerated. - If at least one field or speaker association differs, the row is reported as updated. Only the
fields that actually differ are written (
save(update_fields=...)), so manual local edits to other fields survive.
The end-of-run report breaks down counts:
... created, ... updated, ... unchanged, ... detected, ... deleted, ... skipped, ... failed, ... total.
Rooms (event-scoped, matched by Pretalx id)¶
Rooms belong to an event: the same physical room reused across events is a separate Room row per
event (Room.event, on_delete=PROTECT). Room names are unique per event, not globally.
The importer matches a submission's room by the stable Pretalx room id (slot.room.id, stored on
Room.pretalx_id), falling back to (event, name) for legacy rows that predate id-keying. This is
what makes renames safe:
- Renamed on Pretalx (same id, new name): the existing
Roomis renamed in place, so its streamings,slido_link,capacity, and allTalkFKs stay attached. No orphan row is left behind. (Before id-keying, a rename created a brand-new room and stranded the old one's streamings and config.) - Legacy room matched by name: its
pretalx_idis stamped on the first sync, converting it to an id-keyed row so future renames are handled in place. - New room: created under the event.
- A name match whose stored id differs from the incoming id is logged and left alone (two distinct Pretalx rooms never overwrite each other's id).
In --detect-only mode the Room table is never written: a pending rename is recorded as a
reviewable room field diff (carrying new_pretalx_id) and the actual rename happens only when the
change is applied. A pure rename (the room a talk already sits in was renamed, with no other
change) is detected explicitly so it still shows up for review instead of silently applying only on
a direct sync.
Room.event is required (NOT NULL): every room belongs to exactly one event. The importer
always sets it, and generate_fake_talks uses a synthetic fake-event when no --event-slug is
given rather than producing event-less rooms. PROTECT blocks deleting an event that still has
rooms.
Talk.event is also required, and a talk's room must belong to the talk's own event:
Talk.clean() rejects a cross-event room, and the admin scopes the room dropdown to the talk's
event on the change form. The importer and generate_fake_talks always pair a talk and its room
within one event by construction.
Migrating an existing database¶
Order matters, and back up first
The change ships as a chain of migrations that must run in order (the Room.event column is
nullable only during the backfill window). On a production database, snapshot the DB before applying
migration 0025, and verify no Room.event IS NULL rows remain before 0027 runs.
0024_room_event_pretalx_id- additive, adds nullableevent+pretalx_id(zero-downtime).0025_backfill_room_event- assigns each existing room its event from its talks (a room with no talks falls back to the newest event; a room whose talks span multiple events aborts the migration loudly rather than guessing, since rooms are expected to be per-event).0026_room_event_scoped_constraints- drops the global unique onnameand adds the per-event(event, name)and partial(event, pretalx_id)constraints.0027_room_event_required- tightensRoom.eventto NOT NULL once the backfill has populated every row.0028_backfill_talk_event- assigns each event-less talk its room's event (or the newest event when it has no room), then0029_talk_event_requiredtightensTalk.eventto NOT NULL.
pretalx_id for existing rooms is not backfilled by a migration (there is nothing local to map
from); it is stamped lazily on the next real sync via the (event, name) fallback.
Image (social card) regeneration¶
For an existing talk, the social-card image is regenerated when any of these is true (and
--skip-images is not set):
- The talk's data or speaker set changed (the normal "updated" path).
--force-imageswas passed.- A still-attached speaker's name or avatar changed earlier in the same run. The bulk-upsert step returns the set of changed speaker IDs and the per-talk loop checks for overlap.
- The current image file is missing, or a social-card template PNG for the event was touched after the image's mtime. Useful when you swap a template on disk: the next import re-renders affected cards automatically.
New talks always get an image (unless --skip-images is set).
Detect-and-review workflow¶
--detect-only is the safe periodic-check mode. The importer runs the same diff logic but writes
the result to PendingPretalxChange rows instead of mutating the live Talk / Speaker / Room
graph.
flowchart LR
A[Pretalx API] -->|import_pretalx_talks --detect-only| B[PendingPretalxChange rows]
B --> C{Admin reviews queue}
C -->|Apply selected| D[Live Talks / Speakers / Rooms]
C -->|Dismiss selected| E[Closed: audit trail]
B -->|at least one open row| F[Email digest]
Pending change model¶
Each row carries:
event,pretalx_code,kind(create/update/delete).talkFK (nullforcreate, set tonullondelete).field_diffs-{field: {"old": ..., "new": ...}}for update.speaker_diffs-{"added": [...], "removed": [...]}.pretalx_payload- a snapshot rich enough to apply later without going back to Pretalx.first_detected_at,last_detected_at,applied_at,applied_by,dismissed_at,dismissed_by.
A partial-unique constraint enforces at most one open (neither applied nor dismissed) row per
(event, pretalx_code). Re-running detect for an unchanged diff is idempotent: it refreshes the
same row instead of creating a duplicate. After a row is applied or dismissed, a fresh detection
opens a new row.
Admin UI¶
/admin/talks/pendingpretalxchange/ shows the queue with status, kind, event filters, and a
one-line summary per row. Actions:
- Apply selected pending Pretalx changes - runs
apply_changefor each pending row in the selection. Updates only touch fields recorded infield_diffs, so manual local edits to other fields are preserved. Wrapped in a transaction. - Dismiss selected pending Pretalx changes - flips
dismissed_at. - Check Pretalx now (object-tool button at the top of the list) - synchronously re-runs
import_pretalx_talks --detect-onlyforsettings.DEFAULT_EVENT.
The browser blocks during 'Check Pretalx now'
The request takes 10-30 s for a 500-talk event. The browser is intentionally blocked because there is no worker process to hand the job off to. Once it returns, the page reloads and any new or refreshed rows are visible at the top of the list.
Closed (applied or dismissed) rows stay in the table as an audit trail and are filtered by the status sidebar.
Email digest¶
At the end of any --detect-only run that produced or refreshed at least one pending row, the
importer sends a single plain-text email to the configured recipients. The body contains the per-row
summary (.summarize()) and an admin URL.
PRETALX_DIGEST_RECIPIENTS controls who gets it:
- unset / empty value - fall back to Django's
ADMINSlist (built fromADMIN_NAMESandADMIN_EMAILS). This is what ships out of the box. - comma-separated list of addresses - send to exactly those.
-(single dash) - disable the digest entirely. Useful when you want admins configured but do not want this particular email.
The send uses django.core.mail.send_mail, which routes through whatever email backend is
configured (django-anymail/Mailgun in production, the console or Mailpit backend in dev).
Scheduling periodic checks¶
Cron or a systemd timer is the recommended driver. Detect-only is short and synchronous; it never holds a long-running connection open.
Sample crontab entry (every 10 minutes):
*/10 * * * * cd /srv/pyconde-talks && /srv/pyconde-talks/.venv/bin/python manage.py \
import_pretalx_talks --detect-only --event-slug=pyconde-pydata-2026 >> logs/detect.log 2>&1
Tips:
- Use the venv's
python, not the systempython, so the cron job picks up the project's deps without needingsource. - Redirect stdout and stderr to a log file under
logs/so failed runs are greppable. - Combine with logrotate as you would for any other Django log.
/etc/systemd/system/pretalx-detect.timer is the equivalent if you prefer systemd. The unit file
just calls the same manage.py invocation as the cron entry above.
If you ever decide to graduate from manual review to auto-apply, add a second cron entry that runs
the importer without --detect-only on a slower cadence. The two cron entries can coexist: the
detect job populates the audit table; the apply job is the source of truth for the live data.
Configuration reference¶
Settings the sync feature reads (also in
django-vars.env):
PRETALX_API_TOKEN- API token for the Pretalx instance. Required.DEFAULT_EVENT- event slug used by the admin "Check Pretalx now" button and as the fallback when--event-slugis omitted on the CLI.PRETALX_DIGEST_RECIPIENTS- see Email digest. Defaults to falling back to the admin list.ADMIN_NAMES/ADMIN_EMAILS- the classic Django admin list (combined intoADMINS); reused as the digest fallback recipient list.PICKLE_PRETALX_TALKS- default for--use-cache. Caches fetched submissions on disk for faster repeated local runs. Development only.
Architecture pointers¶
The importer is split into small modules under
talks/management/commands/_pretalx/:
| Module | Responsibility |
|---|---|
client.py |
PretalxClient (httpx2), throttling, setup + fetch with retry |
pretalx_models.py |
Typed Pydantic models for the Pretalx API responses we read |
context.py |
ImportContext, the frozen dataclass passed everywhere |
events.py |
Resolve / create the Event, sync its name from the API |
mixins.py |
The Command plumbing: per-submission loop, mode banners |
submission.py |
Flatten / normalize a Pretalx Submission into SubmissionData |
rooms.py |
Room get-or-create + bulk upsert |
speakers.py |
Speaker get-or-create + bulk upsert (returns visual-change set) |
talks.py |
create_talk, update_talk + change detection (_diff_*) |
images.py |
Social-card generation, template-mtime helpers |
avatars.py |
Avatar prefetch / disk cache |
pending.py |
Detect-only: build diff, upsert PendingPretalxChange rows |
apply.py |
Apply a pending row back onto a live Talk |
digest.py |
Build and send the summary email |
validation.py |
Input validation helpers for fetched submission data |
The admin glue is in
talks/admin_pretalx.py
(bulk actions, "Check Pretalx now" view) and the change-list template at
templates/admin/talks/pendingpretalxchange/change_list.html.