Meetup tooling specification¶
1. Executive Summary¶
The Ansible Community events tooling supports community-owned Meetups using two external systems. Meetup Pro migration utilities are temporary and documented separately under Legacy Meetup Pro migration.
- Pretix (self-hosted, open-source): Event ticketing, RSVP, QR code generation, check-in, and attendee management. Pretix stores no passwords and has no native user registration.
- Discourse (
forum.ansible.com): Identity provider (SSO), community discussion, event agenda hosting, group permissions, and moderation. Discourse is the single source of truth for user identity.
The platform provides:
- Zero-friction RSVP for free community meetups via Discourse SSO into Pretix.
- Regional isolation so local organizers can only see their own city's attendee data.
- Template-driven consistency so every meetup worldwide has identical checkout settings, branding, and QR code delivery.
- Community-first event pages where the Forum topic is the canonical event information page and Pretix handles RSVP and attendee management.
- Automated social media promotion with ready-to-paste copy for 6 platforms.
The system is operated by the Ansible Community Team via CLI scripts. Local organizers interact only through the Discourse forum (to manage event content) and the Pretix dashboard (to view attendees and check in at the door).
2. System Context¶
External Systems¶
| System | Role | API | Auth |
|---|---|---|---|
| Pretix | Event ticketing, RSVP, QR codes, check-in | REST API v1 (/api/v1/) |
Token-based (Authorization: Token {token}) |
| Discourse | Identity (SSO), forum, group permissions, event content | REST API (/posts.json, /admin/groups.json, etc.) |
Global API key (Api-Key + Api-Username headers) |
The Forum/Pretix tools use reusable Requests clients with shared base URLs, headers, and 10-second timeouts. Redirects are disabled so credentials are not forwarded to another URL. Meetup Pro OAuth uses a separate 10-second client; Meetup GraphQL uses a 30-second client. Discourse retries HTTP 429 responses up to three times (four attempts total), honoring Retry-After or Discourse wait metadata and otherwise using exponential backoff. Meetup GraphQL waits 60 seconds after HTTP 429 and retries indefinitely until the response is no longer 429. Other non-200 Meetup GraphQL responses are logged and returned as an empty object without retrying.
Trust Model¶
- Discourse is the single source of truth for identity. Pretix's
NativeAuthBackendis disabled. - All user authentication flows through DiscourseConnect (HMAC-SHA256 SSO). Pretix stores the Discourse
external_idas the identity key. - API tokens (
PRETIX_API_TOKEN,DISCOURSE_API_KEY) are operator secrets. They are never embedded in URLs, logged in error output, or exposed to end users. - The Discourse API key has global admin scope. The
Api-Usernameheader determines which user the action is performed as (impersonation viarun_as).
Operator Model¶
| Actor | Access | How |
|---|---|---|
| Ansible Community Team | Full: create events, manage templates, run all scripts | CLI scripts + API tokens |
| Local Organizer | Scoped: view attendees + check in for their city only | Discourse SSO → Pretix dashboard (mapped via meetup-organisers-{city} group → Pretix Team) |
| Attendee | RSVP only: register for events, receive QR code | Discourse SSO → Pretix checkout |
Configuration: environment variables and constants¶
The Forum/Pretix tools read these environment variables in tools/common/ansible_events_lib.py; the city registry is maintained separately in tools/common/ansible_event_cities.py and imported by the event, provisioning, and attendee scripts:
| Variable | Required | Default | Used for |
|---|---|---|---|
PRETIX_URL |
No | http://localhost:8000 |
Pretix base URL. Non-local URLs must use HTTPS. Production: https://rsvp.ansible.com. |
PRETIX_API_TOKEN |
Yes | None | Pretix API authentication. |
DISCOURSE_API_KEY |
For Forum operations | None | Discourse API authentication. |
tools/attendees/sync_city_groups requires only the Forum key; it does not call Pretix. DISCOURSE_URL=https://forum.ansible.com, DISCOURSE_API_USER=system, the API timeout, and the User-Agent are constants in the shared library, not environment variables.
The Meetup Pro export tools use a separate set of credentials, required only by tools/meetup_pro/meetup_list_groups and tools/meetup_pro/meetup_list_organisers:
| Variable | Required | Purpose |
|---|---|---|
MEETUP_CLIENT_KEY |
Yes | OAuth client key. |
MEETUP_SIGNING_KEY_ID |
Yes | Key identifier used in the signed OAuth assertion. |
MEETUP_MEMBER_ID |
Yes | Meetup member ID used as the assertion subject. |
MEETUP_PRIVATE_KEY |
Yes | Path to the private key file used to sign the OAuth assertion. |
Do not put credentials or private keys in source control, documentation, terminal transcripts, or exported reports.
3. Data Models¶
3.1 CityInfo¶
A frozen, slotted dataclass representing a meetup city.
Fields:
| Field | Type | Example | Description |
|---|---|---|---|
region |
str |
"Europe" |
Geographic region |
country |
str |
"UK" |
Country name or code |
slug |
str |
"london" |
Explicit lowercase ASCII identifier matching ^[a-z]+$ |
city |
str |
"London" |
Display name of the city |
timezone |
str |
"Europe/London" |
IANA timezone identifier |
Properties:
| Property | Derivation | Example |
|---|---|---|
field_value |
f"{region}:{country}:{city}" |
"Europe:UK:London" |
Current Registry (immutable tuple):
CITIES is assembled from the Meetup Pro city groups and historic locations in
tools/common/ansible_event_cities.py. Country-level Meetup Pro groups are
recorded separately and are not treated as event cities. The source file keeps
each section sorted by region, country, and city.
Constraints:
- The registry is an immutable tuple[CityInfo, ...]. Slugs and display names must be unique.
- Each slug must match ^[a-z]+$; each timezone must resolve through Python's IANA zoneinfo database.
- CityInfo instances are frozen — no attribute mutation after construction.
- Keep accepted Unicode spelling in city display names; Pretix and Discourse receive those labels in JSON. Slugs stay lowercase ASCII for identifiers.
3.2 API Results and Errors¶
pretix_req and discourse_req return dict[str, Any] for JSON responses and {} for successful empty responses. Network errors, non-success status codes, invalid JSON, and non-object JSON raise ApiError; they are never represented as None or False. The CLI boundary catches ApiError, reports a concise failure, and exits non-zero. check_event_exists checks the paginated organizer event inventory visible to the configured API token; it does not use detail lookups, which can return 403 for either missing or inaccessible events.
3.3 Master Template¶
A hidden Pretix event (ansible-meetup-template-v6) that is never published. All new meetup events are cloned from it via the Pretix API's clone_from parameter.
Template Settings (enforced on every run of day0):
| Setting | Value | Purpose |
|---|---|---|
max_items_per_order |
1 |
One RSVP per person |
invoice_address_asked |
False |
Free events — no billing |
attendee_names_asked |
True |
Collect real name at checkout |
attendee_names_required |
True |
Name is mandatory |
attendee_emails_asked |
False |
Email comes from SSO |
name_scheme |
"full" |
Single name field (not given/family) |
order_email_asked_twice |
False |
No email confirmation step |
payment_term_last |
null |
No payment deadline (free) |
Template Event Properties:
| Property | Value |
|---|---|
| Slug | ansible-meetup-template-v6 |
| Name | TEMPLATE: Standard Meetup |
| Live | False (never published) |
| Public listing | False |
| Timezone | UTC |
| Currency | USD |
| Plugins | Inherit Pretix's configured default plugins; provisioning does not override the plugin set |
| Default Item | RSVP — price 0.00, active: True, admission: True |
| Default Quota | Capacity — size 100, linked to RSVP item |
3.4 Naming Conventions¶
| Entity | Pattern | Example |
|---|---|---|
| Discourse organiser group | meetup-organisers-{slug} |
meetup-organisers-london |
| Discourse attendee group | meetup-attendee-{slug} |
meetup-attendee-london |
| Discourse category | City name under parent category | London (child of Events, ID 8) |
| Pretix team | Ansible Meetup Organisers - {City} |
Ansible Meetup Organisers - London |
| Pretix staff team | Ansible Meetup Staff |
Ansible Meetup Staff |
| Pretix event slug | {slug}-{mon}-{yyyy} |
london-oct-2026 |
| Forum topic title | Ansible Meetup: {City} - {Month Year} |
Ansible Meetup: London - October 2026 |
3.5 Constants¶
| Constant | Value | Purpose |
|---|---|---|
ORGANIZER_SLUG |
ansible-meetups |
Pretix organizer path segment |
TEMPLATE_SLUG |
ansible-meetup-template-v6 |
Template event identifier |
CONTACT_EMAIL |
ansible-community-events@redhat.com |
Organizer contact email |
DISCOURSE_PARENT_CATEGORY_ID |
8 |
Parent category for regional subcategories |
STAFF_TEAM_NAME |
Ansible Meetup Staff |
Pretix event-access team populated only by Pretix admins |
4. Core Workflows¶
4.1 Environment Reconciliation (tools.environment.provision_environment)¶
Purpose: Reconcile Pretix and Discourse resources to desired state. Resources are resolved by stable name, created if absent, and updated when security-relevant settings drift.
Prerequisites: Set the API credential for each selected phase: PRETIX_API_TOKEN for --pretix and DISCOURSE_API_KEY for --forum. Discourse's enable_category_group_moderation setting must be enabled for the Forum phase; provisioning fails before changing Forum resources if it is disabled or cannot be read.
Run it from the repository root after configuring the environment:
Pass --pretix, --forum, or both to select the reconciliation phases. At least one is required; running without either prints the help text. When both are selected, provisioning completes Pretix setup before reconciling Discourse groups and categories. Each phase checks only its own API credentials, and the Forum phase validates the full meetup-named Discourse group inventory before making changes.
Phase 1: Pretix Global Settings
- Configure organizer settings:
PATCH /api/v1/organizers/ansible-meetups/- Payload:
{"timezone": "UTC", "contact_mail": "ansible-community-events@redhat.com", "settings": {"organizer_team_creation": false}}
Phase 2: Pretix Teams and Metadata
- Reconcile regional teams with Pretix
require_2fa=false: - Fetch all teams using
pretix_list_all("teams")(paginated). - For each city, create a missing team or reconcile its
all_events,all_event_permissions,limit_event_permissions, andlimit_events. The desired event list is rebuilt from all Pretix events whose slug exactly matches{city-slug}-{three-letter-month}-{four-digit-year}; stale and cross-city assignments are removed. - Reconcile the Pretix staff team
Ansible Meetup Staffwith access to all events and all event permissions in the meetup organizer, also withrequire_2fa=false. The provisioner never changes its membership; Pretix admins add and remove staff members. No Forum group grants staff-team membership. - Ensure
forum_topic_urlmeta property exists: GET /api/v1/organizers/ansible-meetups/event_meta_properties/-
If no property named
forum_topic_urlin results:POST /api/v1/organizers/ansible-meetups/event_meta_properties/- Payload:
{"name": "forum_topic_url", "default": "https://forum.ansible.com/c/events/8", "choices": []}Phase 3: Pretix Template
-
Create or reconcile template event:
- Find the exact template slug in the paginated event list.
- If absent:
POST /api/v1/organizers/ansible-meetups/events/- Payload sets the name, slug,
live=false,is_public=false,timezone="UTC", currency, and start date.
- On every run, reconcile
live=false,is_public=false, andtimezone="UTC". - Do not send or verify
is_template: Pretix's public event API does not expose that field. The private event is used as the clone source by its stable slug throughclone_from. -
Reconcile
pretix_passbook_backfieldthroughevents/{template}/item_meta_properties/. This is separate from the organizer-levelforum_topic_urlevent metadata property. -
Enforce template settings:
PATCH /api/v1/organizers/ansible-meetups/events/ansible-meetup-template-v6/settings/-
Payload: See "Master Template Settings" in Section 3.3.
-
Ensure template has a default item + quota:
GET /api/v1/organizers/ansible-meetups/events/ansible-meetup-template-v6/items/- If no items:
POST .../items/→{"name": {"en": "RSVP"}, "default_price": "0.00", "active": true, "admission": true}POST .../quotas/→{"name": "Capacity", "size": 100, "items": [item_id]}
Phase 4: Discourse Groups
- Fetch the paginated Discourse group directory, retaining only groups whose name contains
meetup(case-insensitive). Before any mutations, fail if a name in themeetup-organisersnamespace does not match^meetup-organisers-[a-z]+$. - Reconcile organiser group
meetup-organisers-{slug}by exact name; set group and member-list visibility level 4 (group owners only), disable public admission and membership requests, clear automatic email-domain membership and group owners, and update its description. Forum admins manage membership. - Reconcile attendee group
meetup-attendee-{slug}by exact name; force group and member-list visibility level 3 (staff only). - Reconcile the migration tracking group
meetup-migrated-attendeeswith staff-only visibility (level 3). - Fetch
u/gundalow/invited/pending.jsononce asgundalow. For each city, reuse a pending invite only when its description isJoin Ansible {city display name}and its groups include bothmeetup-attendee-{slug}andmeetup-migrated-attendees. Create missing links with email delivery disabled, both groups, a 5,000-redemption limit, andexpires_atset to2030-01-01. Invalid response data stops invite creation.
Phase 5: Discourse Subcategories and Access
- For each city in the
CITIESregistry, reconcile its regional subcategory by parent and name. Seteveryonetocreate_post(level 2: view and reply, but no new topics), and grantfull(level 1) only to that city's organiser group. Do not grant category permissions to attendee groups; they are used for notifications. Setmoderating_group_idsto that city's organiser group. Then remove registered organiser permissions and moderator assignments from every other category, considering only meetup-named groups. Set each organiser group's default notification level to Tracking for its own subcategory and apply that default to existing group members. Add missing city options to the Discourse user field.
Read-only Setup Diagnostics (tools.environment.check_setup)¶
Run uv run python -m tools.environment.check_setup to inspect local credentials, Pretix and Forum API access, managed resources, template configuration, and gundalow's Forum identity/group membership. The command performs read requests only, reports PASS, FAIL, or MANUAL per check, and exits non-zero when any check fails.
The Pretix GET teams endpoint is permission-gated by organizer.teams:write, so a successful request verifies that permission for the configured API token. Required write permissions that cannot be established without mutation are listed by name as MANUAL: organizer.settings.general:write, organizer.events:create, event.settings.general:write, and event.items:write. Forum write access is also MANUAL; the checker never probes write endpoints.
Pretix teams do not require Pretix-managed 2FA. Privileged SSO logins require Forum 2FA through the signed DiscourseConnect assertion, independently of Pretix team settings.
4.2 Event Creation (tools.events.create_event)¶
Purpose: Provision a Pretix event and social media copy. Forum topic creation and cross-linking are opt-in with --forum. City input must be a registered lowercase ASCII slug.
Run it from the repository root. The default command creates a Pretix event without a Forum post:
Add --forum --organiser FORUM_USERNAME to create a Forum topic and link it to Pretix.
CLI Arguments:
| Argument | Required | Type | Validation | Example |
|---|---|---|---|---|
--city |
Yes | string | ^[a-z]+$; must resolve in CITIES |
london |
--date |
Yes | string | Event local time, ISO-8601 parsed by datetime.fromisoformat |
2026-10-31T18:00:00 |
--capacity |
Yes | int | argparse type=int enforcement |
100 |
--organiser |
With --forum |
string | Discourse username; must be a member of the registered city's organiser group | gundalow |
--forum |
No | flag | Create and link a Forum topic; without it, no Forum API requests or posts are made | --forum |
Derived Values:
| Value | Derivation |
|---|---|
event_timezone |
Registered get_city(city).timezone; unknown city is rejected before API calls |
end_dt |
dt + 3 hours |
start_str |
dt.strftime("%Y-%m-%dT%H:%M:%S") (no Z suffix — local time) |
end_str |
end_dt.strftime("%Y-%m-%dT%H:%M:%S") (no Z suffix — local time) |
event_name |
f"Ansible Meetup {city_info.city}" |
target_slug |
f"{city_info.slug}-{month_slug}-{year}" (e.g., manchester-oct-2026) |
Phase 0: Validation and Pre-flight
- Reject city names outside
^[a-z]+$and names absent fromCITIESbefore API calls. - Parse local date and validate positive capacity before API calls. With
--forum, also require and validate the organiser username, exact organiser-group membership, and Forum API availability. check_event_existschecks the paginated event inventory visible to the configured API token.-
Require HTTPS for
PRETIX_URLexcept localhost and loopback development endpoints. -
Check if
target_slugalready exists in Pretix viacheck_event_exists(target_slug). - If it exists, abort (exit 1) with a clear error. API failures are not interpreted as absence.
Optional Phase 1: Create Discourse Topic (--forum)
POST /posts.json- Headers:
Api-Username: {organiser}(impersonation) - Resolve the exact city subcategory under parent category
8by name. Abort if missing or ambiguous. - Payload:
{"title": "Ansible Meetup: {City} - {Month Year}", "raw": {initial_markdown}, "category": {city_category_id}} - MUST abort (exit 1) if this fails. When
--forumis selected, no Pretix event is created without its Forum topic. - Extract
post_id,topic_slug, andtopic_idfrom response. - Construct
forum_url = f"{DISCOURSE_URL}/t/{topic_slug}/{topic_id}"
Phase 2: Create Pretix Event
POST /api/v1/organizers/ansible-meetups/events/- Payload includes the name, slug, dates, timezone,
clone_from, andmeta_data. With--forum,meta_datacontainsforum_topic_url; otherwise it is empty so no Forum backlink is inherited. - MUST abort (exit 1) if this fails.
- If event creation fails after topic creation, attempt to delete the topic. If the result has no ID or cleanup fails, report that the operator must inspect and remove any orphan before retrying.
- Extract
final_slugfrom response (may differ fromtarget_slugif Pretix appends a suffix). - Construct
pretix_public_url = f"{PRETIX_URL}/{ORGANIZER_SLUG}/{final_slug}/"
Optional Phase 3: Update Discourse Post (--forum)
- Replace placeholder URL in event block:
url="https://link-pending.local"→url="{forum_url}" - Replace RSVP placeholder:
*[RSVP link pending...]*→**[Click here to RSVP via Pretix]({pretix_public_url})** - Replace the share placeholder with the forum topic URL and set the JSON-LD event URL to the forum topic and its Offer URL to the Pretix event.
PUT /posts/{post_id}.json- Payload:
{"post": {"raw": final_markdown}} - Headers:
Api-Username: {organiser}(impersonation) ApiErroraborts the workflow; request failures do not become false success values.
Phase 4: Adjust Quota
GET /api/v1/organizers/ansible-meetups/events/{slug}/quotas/- If quota exists (inherited from template):
PATCH .../quotas/{id}/→{"size": capacity} - If no quota:
GET .../items/to find existing items- If no items: create one (
POST .../items/→{"name": {"en": "RSVP"}, "default_price": "0.00", "active": true, "admission": true}) POST .../quotas/→{"name": "Capacity", "size": capacity, "items": [item_id]}
Optional Phase 5: Set Wallet Pass Back Field (--forum)
- Update ticket item metadata with a link to the Forum topic. This is skipped when
--forumis absent.
Phase 6: Publish
PATCH /api/v1/organizers/ansible-meetups/events/{slug}/→{"live": true}
Phase 7: Assign Team
- Fetch all teams using
pretix_list_all("teams")(paginated). - Find team named
Ansible Meetup Organisers - {City}. - The provisioner derives each city's complete
limit_eventslist from the paginated Pretix event inventory using the exact canonical event-slug pattern. It replaces the current list, removing stale or cross-city event grants. - Event creation adds its new slug to the matching team's list; the provisioner later recomputes that list from event slugs.
PATCH /api/v1/organizers/ansible-meetups/teams/{id}/→{"limit_events": [...], "all_event_permissions": false, "limit_event_permissions": ["event.orders:read", "event.orders:checkin"]}
Phase 8: Social Media Copy
- Print to stdout (not file, not API).
- See Section 8 for full specification.
4.3 SSO Authentication¶
Authentication is provided by the separate meetup-pretix-discourse-auth plugin. This repository provisions the Forum and Pretix resources used by that plugin; it does not implement or diagnose the SSO flow.
5. API Integration Contracts¶
5.1 Pretix API¶
Base URL Construction:
Trailing slash rules:
- If URL has no query string: ensure it ends with /.
- If URL has a query string: split on first ?, ensure the base ends with /, rejoin.
- Never produce double slashes (except in ://).
Authentication: Authorization: Token {PRETIX_API_TOKEN} header on every request.
Content-Type: application/json on every request.
Timeout: 10 seconds on all requests.
Response Handling:
| Status | Behavior |
|---|---|
| 200, 201, 204 | Success. Return JSON object, or {} for an empty body. |
| Any other | Raise ApiError with status and bounded response excerpt. |
| Connection error | Raise ApiError; never infer resource absence. |
| Invalid/non-object JSON | Raise ApiError. |
Endpoints Used:
| Method | Endpoint | Purpose |
|---|---|---|
PATCH |
(empty — targets organizer) | Update organizer settings |
GET |
event_meta_properties |
List meta properties |
POST |
event_meta_properties |
Create forum_topic_url property |
GET |
events/{slug}/item_meta_properties |
List event item metadata properties |
POST |
events/{slug}/item_meta_properties |
Create template wallet-pass back-field property |
GET |
events |
Check existence among events visible to the configured API token |
POST |
events |
Create event (or clone from template) |
PATCH |
events/{slug} |
Update event (publish, set name) |
PATCH |
events/{slug}/settings |
Update template checkout settings |
GET |
events/{slug}/items |
List ticket items |
POST |
events/{slug}/items |
Create ticket item |
PATCH |
events/{slug}/items/{id} |
Set Forum link in wallet pass metadata (only with --forum) |
GET |
events/{slug}/quotas |
List quotas |
POST |
events/{slug}/quotas |
Create quota |
PATCH |
events/{slug}/quotas/{id} |
Update quota size |
GET |
teams |
List all teams (paginated via pretix_list_all) |
GET |
events |
Paginated inventory used to reconcile city team event scope |
POST |
teams |
Create team |
PATCH |
teams/{id} |
Update team permissions/event limits |
5.2 Discourse API¶
Base URL Construction:
No trailing slash appended (Discourse endpoints include their own suffixes like .json).
Authentication: Api-Key: {DISCOURSE_API_KEY} + Api-Username: {run_as or DISCOURSE_API_USER} headers.
Content-Type: application/json.
Timeout: 10 seconds.
Response Handling:
Same typed contract as Pretix. Duplicate creation errors are not swallowed; provisioning first resolves resources by name and updates them.
Endpoints Used:
| Method | Endpoint | Purpose |
|---|---|---|
POST |
admin/groups.json |
Create Discourse group |
POST |
categories.json |
Create regional subcategory |
GET |
u/gundalow/invited/pending.json |
List pending invites for reconciliation |
POST |
invites.json |
Create city attendee invite link |
POST |
posts.json |
Create forum topic (only with --forum) |
PUT |
posts/{id}.json |
Update post content (only with --forum) |
SSO Toolkit Additional Endpoints (urllib):
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/admin/users/{external_id}.json |
Fetch moderation/status data; 2FA is asserted by signed DiscourseConnect response |
GET |
/user-badges/{username}.json |
Fetch user badges |
6. Security Requirements¶
Input Validation¶
| Input | Rule | Enforcement Point |
|---|---|---|
--city |
^[a-z]+$, registered in CITIES |
Reject before API calls or mutations |
--date |
Valid ISO-8601 | datetime.fromisoformat() — raises ValueError |
--capacity |
Integer | argparse type=int — rejects non-integers |
--organiser |
Optional Discourse username | Required with --forum; membership-checked, then used as the Discourse Api-Username |
--forum |
Boolean flag | When present, create the Forum topic and backlinks; absent by default |
SSO Security¶
The separate meetup-pretix-discourse-auth plugin owns SSO signature, nonce, rate-limit, and session security.
Log Sanitization¶
- API error response bodies truncated to 200 characters:
resp.text[:200]. - API tokens are in headers only, never in URLs or log messages.
- Requests exception messages do not include request headers.
Attack and Misconfiguration Model¶
- A Pretix lookup returning an error or unknown status is never treated as proof that an event does not exist; the operation stops with
ApiError. - A city slug must match
^[a-z]+$, be registered inCITIES, and equal its Pretix team display-name suffix normalized with Unicode NFKD, retaining only ASCII letters and lowercasing (New York→newyork,São Paulo→saopaulo). Update and deploy this tooling registry before provisioning or event creation. The auth plugin is city agnostic and resolves existing city teams dynamically; adding a city requires no edits or release inpretix-discourse-auth. - The auth plugin accepts only exact
^meetup-organisers-([a-z]+)$Forum group claims for city access. Staff privileges come only from Pretix'sAnsible Meetup Staffteam or Pretix-managedis_staff; Forum claims cannot grant them. Organizer, staff-team, and Pretix staff accounts require a signedconfirmed_2fa=trueassertion. Every claimed organiser group must resolve to a unique Pretix team or that organizer login is denied; missing teams, duplicate teams, and normalization collisions fail closed. - The auth callback limits requests to 10 weighted attempts per client IP per 60 seconds; signature and nonce failures count twice. Rate-limit state uses the shared Pretix cache and fails closed if the cache is unavailable.
- Discourse groups are reconciled by name on every provisioning run. Organiser groups are hidden (level 4), have staff-only member lists (level 3), and are managed by Forum admins; attendee groups and their member lists are staff-only (level 3). Organiser group permissions and moderator assignments are removed from every category except that group's own city subcategory. City Pretix teams are reconciled to the exact canonical event slug pattern; the Pretix-only staff team retains access to all events and is never populated by Forum claims. Provisioning errors are fatal.
- Non-local
PRETIX_URLvalues must use HTTPS so API tokens are not sent over cleartext HTTP. - Pretix session expiry is controlled by Pretix core, not its organizer API. The auth plugin disables “keep me logged in” and sets a browser-session cookie; Pretix core enforces its configured relative idle timeout (three hours in this deployment) and 12-hour absolute limit. Changing the absolute limit requires a Pretix server override, so
provision_environment.pydoes not attempt to set an unsupported API field.
Known Accepted Risks¶
run_asscope:create_event.py --forum --organiserimpersonates the selected Discourse user when posting. Membership is validated first; API key scope remains important.
7. Forum Post Template¶
The forum post created by create_event.py has two distinct zones separated by a horizontal rule (---):
Zone 1: Event Widget
[event start="{start_str}" end="{end_str}" timezone="{event_timezone}" minimal="true" name="{event_name}" url="https://link-pending.local"]
[/event]
minimal="true": Shows compact event widget without Interested/Attending buttons.- No
status="public": Prevents RSVP buttons (RSVP is handled by Pretix). - When
--forumis enabled,urlis initially a placeholder and is replaced with the Forum topic URL in Phase 3.
Zone 2: Forum Event Content
## Welcome to Ansible Meetup {City}!
Join us for an evening of automation, collaboration, and community! ...
### What to Expect
- **Engaging Talks**: ...
- **Networking**: ...
- **Q&A**: ...
- **Community**: ...
### Location
**Venue details will be announced soon!** ...
### Agenda
* *Speaker 1: TBD*
* *Speaker 2: TBD*
*(Want to give a talk? Reply below with your proposal!)*
### RSVP
**[Click here to RSVP via Pretix]({pretix_public_url})**
**Please note:** To help us manage venue capacity ...
### Connect
Have questions? Want to propose a talk? ...
Placeholder Replacements (Phase 3):
| Placeholder | Replacement |
|---|---|
url="https://link-pending.local" |
url="{forum_url}" |
| RSVP and share placeholders | With --forum, link to the Pretix event and Forum topic; otherwise use the Pretix event URL |
When --forum is enabled, the Forum post is the event content source. Pretix stores forum_topic_url as a backlink and does not receive a copy of the Forum content. Without the flag, no Forum post or backlink is created.
8. Social Media Copy Specification¶
Generated after successful event provisioning. Printed to stdout for manual copy-paste.
| Platform | Char Limit | Hashtags | Link Target | Fallback |
|---|---|---|---|---|
| X / Twitter | 280 | #Ansible #Meetup #OpenSource |
Forum URL with --forum, otherwise Pretix URL |
Shorter text if over limit |
| Bluesky | 300 | #Ansible #DevOps #Automation |
Forum URL with --forum, otherwise Pretix URL |
Shorter text if over limit |
| Mastodon | 500 | #Ansible #Meetup #DevOps #Automation #OpenSource #InfrastructureAsCode |
Forum URL with --forum, otherwise Pretix URL |
None (always fits) |
| None | #Ansible #DevOps #Automation #InfrastructureAsCode #CommunityMeetup |
Forum URL with --forum, otherwise Pretix URL |
None | |
| None | None (markdown) | Forum URL with --forum, otherwise Pretix URL |
None | |
| Hacker News | None | None | Forum URL with --forum, otherwise Pretix URL |
Title only, link on separate line |
All platforms link to the Forum URL when a topic is created; otherwise they link directly to the Pretix event.
X and Bluesky include a fallback mechanism: if the primary text exceeds the character limit, a shorter version is used. Character count is printed after each payload.
9. Non-Functional Requirements¶
HTTP¶
- Timeout: 10 seconds on all Pretix/Discourse API calls and urllib calls. Meetup Pro OAuth uses 10 seconds; GraphQL uses 30 seconds.
- Rate-limit retries: Discourse requests allow at most three retries after the initial request (four attempts total) for HTTP 429, using
Retry-After, Discourse wait metadata, or exponential backoff. Meetup GraphQL requests wait 60 seconds and retry indefinitely while HTTP 429 continues. Pretix and Discourse wrapper errors stop the CLI; Meetup GraphQL logs other non-200 responses and returns an empty object without retrying. - Connection pooling: Reusable
requests.Sessionclients pool connections across API requests.
Logging¶
- Python
loggingmodule, logger nameansible_meetups. - Level:
INFOby default. - Format:
%(asctime)s - %(levelname)s - %(message)s. - Error responses: log at
ERRORwith status code + truncated body (200 chars max). - Failures where script can continue: log at
WARNING.
Type Safety¶
from __future__ import annotationsin all files.- Full type annotations on all function signatures (parameters + return types, including
-> None). - API wrappers return JSON objects and raise
ApiErroron failure; create/read operations require a JSON object response. - Pyright with
typeCheckingMode = "standard"(must pass with zero errors). - Frozen slotted dataclasses for data models.
Code Quality¶
- Formatter:
ruff format(line-length 120). - Linter:
ruff checkwith rulesE,F,B,S,T20,DTZ,UP,SIM, andG. - Per-file ignores: test assertions (
S101); event social-copy output and long lines (T201,E501); the Meetup Pro credential example (S105); and long organiser export output (E501). - Import ordering: CI does not run a separate isort check.
Testing¶
- Framework: pytest with
testpaths = ["tests"]. - Organization: Class-based (
Test<Feature>), methods namedtest_<verb>_<condition>. - Coverage areas (66 test methods):
CityInfo.slug: lowercase, spaces→hyphens, multiple spaces, empty, leading/trailing, tabs, unicode.CityInfo.field_value: standard, multi-word, case preservation.CityInfofrozen: immutability enforcement.get_city: exact match, case-insensitive, not-found, partial match rejection, empty string, leading/trailing spaces.CITIESregistry intools/common/ansible_event_cities.py: every city resolves by slug; country names match the expected set; metadata and timezones validate; sections stay sorted; accepted Unicode spellings are preserved.- Pretix URL building: trailing slashes, query strings, double slashes, empty endpoint.
- Discourse URL building: slash stripping, nested paths.
pre_flight_checks: both vars set, missing discourse key,require_discourse=False.
Dependencies¶
- Runtime:
requests>=2.32andPyJWT[crypto]>=2.10(used by the temporary Meetup Pro OAuth exporters). - Development group:
pyright>=1.1,pytest>=8,ruff>=0.11, andtypes-requests>=2.32. - Documentation group:
mkdocs>=1.6andmkdocs-ansible>=0.1. - Package manager and lock: uv with
uv.lock;tool.uv.package = false, so this tools-and-docs repository is not built as an installable Python package. - Python:
>=3.13. - CLI layout: Active tools are Python modules under
tools/common/,tools/events/,tools/environment/,tools/attendees/, andtools/meetup_pro/. The existingtools/cfgmgmtcamp/utility is separate from the Meetup tool workflows. - Docstrings: Python functions and methods should use Google-style docstrings where appropriate; CI does not run a docstring-specific checker.
- Discourse theme component: Event JSON-LD source and metadata are maintained in this repository at
discourse-theme/event-jsonld/.
Continuous integration¶
GitHub Actions uses astral-sh/setup-uv@v5 to select Python 3.13, then runs uv sync --locked --all-groups. On pull requests and pushes to main or master, it runs pytest, Ruff lint and format checks on the Meetup tool modules and tests, Pyright on tools/common/ansible_event_cities.py, tools/common/ansible_events_lib.py, and tools/environment/provision_environment.py, and mkdocs build --strict. CI does not build a Python wheel and does not include tools/cfgmgmtcamp/ in its Python checks.
10. Checkout Experience Configuration¶
The Pretix checkout flow for authenticated attendees is:
- User clicks RSVP link → Pretix redirects to Discourse SSO.
- User authenticates on Discourse → redirect back to Pretix with signed payload.
- SSO plugin pre-fills email from payload. Name pre-filled if available.
- Checkout page shows: event name, single "Name" field (pre-filled, editable), RSVP button.
- No email confirmation step, no payment step, no invoice address, no copy-answers button.
- User submits → receives confirmation email with QR code and ICS calendar attachment.
Name Behavior:
- Pre-populated from Discourse SSO name field during login.
- User CAN edit their name during Pretix checkout.
- Edits are stored ONLY in Pretix. The Discourse profile is NEVER modified.
- Uses name_scheme: "full" — single text field, not given/family split.
The checkout settings are enforced on the template and inherited by cloned events; see the master template table in Section 3.3. The event creation workflow also adjusts the event quota to the requested capacity.
11. Implicit Assumptions & Shortcuts¶
Retry boundaries¶
Pretix requests and non-429 Forum/Meetup failures are not retried. Forum API requests retry HTTP 429 at most three times after the initial attempt; if rate limiting persists, the command fails. Meetup GraphQL waits 60 seconds and retries HTTP 429 indefinitely; other non-200 responses are logged and produce an empty object. There is no general retry queue or circuit breaker.
Recovery after Partial Failure¶
create_event.py validates configuration and remote prerequisites before mutation. When --forum is enabled and Pretix event creation fails after the Discourse topic was created, it attempts to delete that topic. If cleanup fails, an operator must remove the orphan before retrying. Later API failures can still leave a partially configured event and must be resolved before announcing it.
The script aborts on API errors. Review the event and, when --forum was used, the Forum topic before announcing it; later mutations can leave partially configured resources that require operator repair.
Partial Pagination¶
The teams list endpoint is paginated via pretix_list_all. Other list endpoints (items, quotas, event_meta_properties) still assume results fit in a single page (<50 results). At scale, these may need migration to pretix_list_all.
Template Must Exist Before Event Creation¶
create_event.py uses clone_from: ansible-meetup-template-v6. It checks that the template exists before mutation. Run provision_environment.py to create or reconcile it.
Hardcoded Cities Registry¶
The CITIES tuple is defined in tools/common/ansible_event_cities.py. Adding a new city requires a code change and deployment in this repo, followed by provisioning of its Forum group and Pretix team. The auth plugin resolves city teams dynamically and requires no city-specific code change or release. There is no database, config file, or API for dynamic city management.
Social Media Output¶
Social media copy is printed to stdout. There is no file output, clipboard integration, or API posting. The operator manually copies and pastes into each platform.
Event Duration Fixed at 3 Hours¶
create_event.py hardcodes end_dt = dt + timedelta(hours=3). There is no CLI argument for event duration.
Currency¶
All events use "currency": "USD" (inherited from the template). No per-city currency configuration exists. Since all events are free, this only affects display on confirmation emails.
12. Open Questions & Clarifications¶
Q1: Partial Failure Compensation¶
With --forum, the script attempts compensating cleanup if Pretix event creation fails. If the cleanup API call also fails, delete the topic manually before retrying. Unknown Pretix event state is fatal and never treated as absence.
Q2: Scale & Pagination¶
How many cities will this system support at maturity? The current implementation performs sequential reconciliation. See “Partial Pagination” above for list-endpoint limits to consider as the city count grows.
Q3: Cities Registry Storage¶
Who adds new cities? Should the registry be:
- Hardcoded (current) — requires code deployment for each new city.
- Config file (YAML/JSON) — editable without code changes.
- Discourse-driven — auto-discover cities from existing meetup-organisers-* groups.
- Database — for API-managed CRUD.
Q5: Event Duration¶
Event duration is hardcoded at 3 hours. Should the rebuild accept a --duration CLI argument? Most meetups are 2-3 hours, but unconferences or all-day workshops may need longer.
Q6: Pretix staff team¶
The provisioner creates Ansible Meetup Staff with access to all meetup events. Only Pretix admins manage its membership. The Forum contains no meetup-admin or meetup-staff groups, and the auth plugin never maps Forum groups to this team.
Q7: Pretix API Rate Limits¶
What are the Pretix instance's API rate limits? provision_environment.py makes multiple API calls per city during reconciliation. At 50 cities, that's hundreds of sequential requests. Do we need request throttling or backoff? What about the Discourse API rate limits?
Canonical documentation¶
This specification is maintained in the Ansible Meetup documentation repository. When requested with --forum, the Forum event topic is the canonical event information page. It links to Pretix for RSVP, and Pretix stores a backlink to the Forum topic; event details do not synchronize between the systems.