Skip to content

feat: Discourse Pretix - #1

Closed
gundalow wants to merge 73 commits into
mainfrom
feat-initial-support
Closed

gundalow wants to merge 73 commits into
mainfrom
feat-initial-support

Conversation

@gundalow

@gundalow gundalow commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

This PR provides the Ansible Community meetup tooling for provisioning Discourse and Pretix, creating linked meetup events, synchronizing event content, and supporting the Meetup Pro migration.

Security and access model

  • Any valid Discourse account can sign in to Pretix as an attendee, subject to the moderation and account checks in the auth plugin.
  • Exact meetup-organisers-{city-slug} claims map through the registered city list to city-scoped Pretix teams. Organizer teams receive only their own city's event and category access.
  • Forum organizer groups are hidden, expose member lists to staff only, have no owners or public admission, and are managed by Forum admins. Organizer permissions are removed from every other category and subcategory.
  • Pretix provisioning creates the Ansible Meetup Staff team with access to all events in the meetup organizer. Pretix admins alone add or remove its members. No Forum group grants staff or site-wide Pretix administrator access; Pretix is_staff remains under Pretix administrator control.
  • Provisioning reconciles groups, category permissions, template settings, event assignments, and team permissions on each run, then verifies the security-sensitive saved state and fails on mismatches.

Main components

  • scripts/ansible_events_lib.py defines explicit city slugs and canonical display names, strict API clients, pagination, and shared policy constants.
  • scripts/provision_environment.py provisions Forum groups/categories and Pretix templates/teams to their desired state.
  • scripts/create_event.py validates the registered city and event inputs before mutation, creates the Forum topic only in that city's Events subcategory, and creates the linked Pretix event.
  • scripts/pretix-sync-event.py synchronizes the canonical Forum topic content to Pretix.
  • scripts/sync_city_groups.py reconciles profile-field subscriptions with hidden attendee groups.
  • scripts/discourse-connect-mvp.py is a local diagnostic tool, not the production authentication authority.

City slugs and canonical team names are a documented contract with the Pretix auth plugin: a new city must be added to both repositories before deployment and provisioning. Non-local Pretix URLs must use HTTPS.

feat: Capture more information

initial cookie cutter

chore: remove plugin

chore: Discourse Connect MVP

feat: Pretix: Create events & teams

feat: Day 0 Pretix configuration

chore: 'Ansible Meetup Organisers - {city}' (not 'Staff')

chore: Refactor: ansible_meetup_lib.py

chore: Pretix templates created

chore: rename library

docs: Ideas for future work

chore: pretix template v6

chore rename shared library

feat: create_event to generate forum post THEN pretix

refactor: consolidate constants and common code into ansible_events_lib

Move duplicated API config, request wrappers, cities registry, template
slug, category IDs, contact email, and helper functions into the shared
library. All consumer scripts now import from ansible_events_lib instead
of defining their own copies. Cities list updated to structured dicts
with region/country/timezone (London, Barcelona, FakeTown).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

chore: add lint.sh and auto-fix formatting across all scripts

Add lint.sh that runs isort, ruff format, ruff check --fix, and
py_compile across all Python files. Applied the formatter to fix
import ordering, trailing commas, and line length issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

fix: harden error handling across all scripts

- Add RequestException handling to pretix_req, discourse_req, and
  check_event_exists so network errors are logged instead of crashing
- Add JSONDecodeError handling for non-JSON API responses
- Guard against None returns before calling .get() in create_event.py
  and pretix-sync-event.py
- Guard against empty posts list in pretix-sync-event.py
- Add pre_flight_checks(require_discourse=False) to pretix-create-groups
- Make pre_flight_checks accept require_discourse flag for Pretix-only scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: simplify Pretix checkout with SSO-friendly template settings

Enable attendee name collection (pre-populated from Discourse SSO,
editable locally without writing back to forum). Simplify checkout by
removing duplicate email confirmation, payment term for free events,
and copy-answers button. Use single "full" name field instead of
given/family split.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: improve forum post template and add social media payload generation

Forum post template improvements:
- Remove status="public" from Discourse event tag (hides unnecessary
  Interested/Attending buttons since RSVP is via Pretix)
- Add --- separator between Discourse-only event block and synced content
- Only sync content below the separator to Pretix frontpage (event
  BBCode won't render in Pretix)
- Add Welcome, What to Expect, Location, and Connect sections
- Improve Agenda section with talk proposal CTA

Social media copy generation:
- Print ready-to-paste text for X, Bluesky, Mastodon, LinkedIn,
  Reddit, and Hacker News after event provisioning
- All posts link to the forum URL (community hub), not Pretix directly
- Character-limited platforms include fallback text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs: expand FUTURE_WORK.md to 20 ideas for Meetup Pro migration

Add 13 new enhancement ideas focused on reducing friction from
migrating off Meetup Pro and improving event discoverability:
event discovery landing page, email reminders, waitlist management,
organizer analytics, recurring series, calendar feeds, plus-one
guests, no-show tracking, feedback collection, Meetup.com import
tool, mobile check-in, cross-city promotion, and photo galleries.

Mark social media payload generation as implemented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

fix: guard against non-dict API returns and silent failures

- Add isinstance(dict) checks where API wrappers may return True
  instead of a dict (empty 204 responses), preventing AttributeError
  on .get() calls
- Fix None.lower() crash in anonymised() when SSO payload fields are
  null (use `or ""` pattern instead of default empty string)
- Add warning logs for unchecked API responses in pretix-day0.py and
  pretix-sync-event.py so failures are visible

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

refactor: add type annotations and tidy code per coding standards

- Add type annotations to all functions (parameters and return types)
- Add ApiResponse type alias for the mixed dict|bool|None return type
- Make run_as and require_discourse keyword-only arguments
- Extract print_social_media_copy() from main() for readability
- Add from __future__ import annotations across all scripts
- Add -> None to all main() and void functions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: migrate to uv, httpx, and CityInfo dataclass

Tooling migration:
- Replace pip/venv with uv for dependency management
- Replace requests with httpx for HTTP client
- Add dev dependency group (pytest, pytest-cov, ruff, isort, pyright)
- Enable stricter ruff rules (B, S, T20, DTZ, UP, SIM)
- Add pyright with standard type checking mode
- Update lint.sh to use uv run

Data model:
- Replace dict-based cities with frozen slotted CityInfo dataclass
- Add .slug and .field_value properties (replaces standalone functions)
- CITIES is now an immutable tuple
- All consumers updated to use attribute access (city.city, city.timezone)

Code quality:
- Add Google-style docstrings to all public API functions
- Fix pyright errors (isinstance narrowing, return type annotations)
- All scripts pass pyright with zero errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: add unit tests and update DESIGN.md

Tests (43 tests):
- CityInfo.slug: lowercase, spaces, tabs, unicode, empty, leading/trailing
- CityInfo.field_value: standard format, multi-word, case preservation
- CityInfo frozen: immutability verification
- get_city: case-insensitive lookup, partial match rejection, not found
- CITIES registry: required keys, no duplicates, valid timezones, immutable
- Pretix URL building: trailing slashes, query strings, empty endpoint
- Discourse URL building: slash stripping, nested paths
- pre_flight_checks: env var validation, require_discourse flag

DESIGN.md:
- Add Checkout Experience subsection (SSO name, minimal checkout)
- Add Forum Post Structure subsection (--- separator, Pretix sync)
- Update all script descriptions to match current implementation
- Remove meetup_organizers_export.py (does not exist)
- Add lint.sh to inventory
- Update cities to London, Barcelona, FakeTown

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

fix: address security findings across all scripts

XSS fixes (discourse-connect-mvp.py):
- HTML-escape the /debug endpoint JSON output to prevent script
  injection via user-controlled SSO fields (name, email, username)
- HTML-escape error status in 403 response page
- Fix SSO nonce bypass: reject empty/missing nonces instead of
  allowing hmac.compare_digest('', '') to pass

Input validation:
- Validate --city CLI arg allows only letters, spaces, and hyphens
  to prevent path traversal in Pretix API slug construction
- Parse forum_topic_url with urlparse and validate topic ID is
  numeric before using it in Discourse API calls

Log sanitization:
- Truncate API error response bodies to 200 chars to limit PII
  exposure in log output (attendee emails, names, order details)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs: add security findings to FUTURE_WORK.md

Document resolved security fixes (XSS, nonce bypass, PII in logs,
input validation, URL parsing) and remaining accepted-risk items
(HTML sanitization relay, SSO toolkit hardening, run_as scope).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

fix: tighten input validation on CLI arguments

- City regex: replace \s with literal space to reject tabs/newlines
  that would produce malformed slugs and event names
- Slug validation: add ^[a-z0-9\-]+$ check on --slug argument in
  pretix-sync-event.py to prevent path traversal in API URLs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs: add comprehensive technical specification for rebuild

800-line specification covering the complete system: executive summary,
system context and trust model, data models (CityInfo, ApiResponse,
template settings, naming conventions), all 5 core workflows with
exact API endpoints and payloads, API integration contracts for both
Pretix and Discourse, security requirements, forum post template
structure, social media copy spec, NFRs (timeouts, logging, types,
testing, tooling), checkout experience configuration, 12 implicit
assumptions/shortcuts, and 7 open architecture questions.

Written to enable a from-scratch rebuild without access to the
original codebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

fix: resolve 4 spec review findings

1. Timezone alignment: Use CityInfo.timezone in Discourse [event]
   block and Pretix event creation payload instead of hardcoded UTC.
   Falls back to UTC for cities not in the registry.

2. Sync format standardization: pretix-sync-event.py now fetches raw
   markdown via GET /posts/{id}.json and strips the [event] block via
   --- separator, matching create_event.py behavior. No more Discourse
   CSS classes in Pretix frontpage.

3. Safe team updates: Add pretix_list_all() pagination helper. Team
   assignment now fetches the full team by ID before read-modify-write
   on limit_events, preventing truncation from paginated list results.

4. Idempotency: create_event.py checks if the target Pretix event
   slug already exists before creating anything, preventing duplicate
   events on retry after partial failure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: resolve audit findings — naming, permissions, timezone, dead code

Team naming: rename from "Staff" to "Organisers" in both day0 and
create_event (matches organizer-facing terminology).

Permissions: migrate from deprecated can_view_orders/can_change_orders
/can_checkin_orders fields to limit_event_permissions with granular
string values (event.orders:read, event.orders:checkin).

Discourse groups: add meetup-admins group for Ansible Community Team
(visibility_level 1 = owners only).

Date semantics: --date is now interpreted as event local time (not
UTC). The Z suffix is stripped, and timestamps are passed through as
naive datetimes with the event_timezone attribute controlling display.

Dead code: remove pretix-create-groups.py (hardcoded dates, wrong
currency, different naming convention, superseded by day0 + create_event).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs: sync all documentation with current codebase

Update SPECIFICATION.md, DESIGN.md, and FUTURE_WORK.md to reflect:
- Team naming: "Staff" → "Organisers" throughout
- Permissions: deprecated fields → limit_event_permissions format
- New meetup-admins Discourse group for Community Team
- Date semantics: --date is event local time (not UTC)
- Timezone: events use CityInfo.timezone, not hardcoded UTC
- Sync format: raw markdown via GET /posts/{id}.json + --- stripping
- Pagination: pretix_list_all() documented
- Idempotency: pre-flight check documented
- CityInfo: standalone functions → dataclass properties
- Removed pretix-create-groups.py from all references
- Security: HTML relay risk → markdown relay risk

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

refactor: consolidate naming patterns, eliminate duplication

Data model:
- Add CityInfo properties: event_name, team_name, host_group,
  attendee_group (derived from slug + naming constants)
- Add naming constants to lib: HOST_GROUP_PREFIX, ATTENDEE_GROUP_PREFIX,
  ADMIN_GROUP_NAME, EVENT_NAME_PREFIX, ORGANISER_TEAM_PREFIX
- Add ORGANISER_PERMISSIONS constant (shared between day0 + create_event)
- Add strip_discourse_block() helper (shared between create_event + sync)

Code cleanup:
- Move all inline imports (re, urlparse) to top-level
- Replace duplicated string formatting with CityInfo properties
- Replace duplicated permission lists with ORGANISER_PERMISSIONS constant
- Replace duplicated --- stripping logic with strip_discourse_block()
- Fix log message "Staff Team" → "Organisers Team"

Tests (56 total, 13 new):
- CityInfo derived properties: event_name, team_name, host_group,
  attendee_group, space handling, case preservation
- strip_discourse_block: separator splitting, no separator, multiple
  separators, empty above/below, whitespace stripping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs: add community announcement draft for meetup migration

Draft forum post explaining the move from Meetup Pro to community-owned
infrastructure. Covers: why the change, what's different for attendees
and organisers, how it works, timeline (London + Barcelona first),
transition plan (parallel operation), and FAQ.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs: rewrite FORUM_DRAFT.md as structured community proposal

Restructured as an RFC-style proposal covering: objectives, workflows
for all three personas (attendee/organiser/admin), data protection and
security, event promotion strategy (immediate/short-term/long-term),
targeted questions for each persona, and transition timeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

fix: resolve cross-repo inconsistencies with auth plugin

Admin group: meetup-admins (plural) → meetup-admin (singular) to match
the plugin's staff_group default.

SSO toolkit RTBF detection:
- Fix email suffix: @example.invalid → @anonymized.invalid (verified
  against Discourse UserAnonymizer source)
- Remove false-positive username/name heuristics (plugin spec Q5
  decision: email-domain only)
- Extract is_anonymised(), is_silenced(), is_suspended() functions

SSO toolkit silenced/suspended detection:
- Fix: check silenced_till/suspended_till (datetime, conditionally
  present) instead of silenced/suspended (boolean fields that don't
  exist in the Discourse Admin API)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs: sync all docs with cross-repo audit fixes

- meetup-admins → meetup-admin (singular) throughout
- SSO toolkit docs: document is_anonymised(), is_silenced(),
  is_suspended() helper functions with correct field names
- RTBF: @example.invalid → @anonymized.invalid
- Silenced/suspended: document silenced_till/suspended_till fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

refactor: rename provision script, fix logging, consolidate constants

Rename pretix-day0.py → provision_environment.py (reflects that it
provisions both Discourse and Pretix, and is designed to run
repeatedly, not just on day zero).

Logging: convert all f-string logging to lazy % formatting across all
scripts. Enable ruff G rule (flake8-logging-format) to enforce.

Constants: add RTBF_EMAIL_SUFFIX, DEFAULT_ITEM_NAME, DEFAULT_ITEM_PRICE,
DEFAULT_QUOTA_NAME to shared lib. Update create_event.py and
provision_environment.py to use them instead of inline strings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: organiser validation, improved forum template, check-in plugins

CLI: rename --host → --organiser. Validate the organiser exists as a
Discourse user AND is a member of the meetup-host-{city} group before
creating anything.

Lib: add discourse_user_exists() and discourse_user_in_group() for
organiser validation. Add TEMPLATE_PLUGINS with expanded plugin list
(checkinlists, pretixdroid, webcheckin, statistics).

Forum post template improvements:
- Details table with date, time (with timezone), venue, cost, organiser
- Structured agenda table with time slots and speaker columns
- "Share" section with pre-written social copy for attendees
- Organiser attribution at the bottom
- Share link placeholder replaced with forum URL on update

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs: add engineering and organiser guides, update FUTURE_WORK

GUIDE_ENGINEERING.md: CLI operations manual for the Community Team
covering prerequisites, initial setup, event creation, content sync,
adding cities/organisers, development workflow, and troubleshooting.

GUIDE_ORGANISER.md: step-by-step guide for volunteer organisers
covering event page editing, attendee management, check-in with
pretixSCAN, promotion, talk proposals, and post-event activities.

FUTURE_WORK.md updates:
- Mark run_as impersonation scope as RESOLVED (organiser validation
  now checks group membership before creating content)
- Add item 21: "Share I'm Attending" Pretix plugin (research complete,
  uses order_info_top signal + event meta_data for forum_topic_url)
- Add item 22: Apple/Google Wallet passes (pretix-passbook package)
- Add item 23: Device check-in code provisioning (Pretix devices API)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs: rewrite organiser guide with terminology and context first

Restructure to lead with concepts before procedures:
- Add "How It All Fits Together" section: two systems (Discourse +
  Pretix), forum structure (categories, subcategories, topics),
  groups and roles table, responsibility matrix (team vs organiser)
- Move 2FA requirement to prominent position with step-by-step
  enable instructions and exact error message shown when missing
- Add visual template structure showing event post zones
- Add metrics table (registered/capacity/available/checked-in)
- Add explicit 2FA FAQ entry explaining why it's required

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: add group descriptions with requirements and purpose

Set bio_raw on all Discourse groups created by provision_environment:

meetup-admin: states global admin access, requires 2FA + Community
Engineering lead approval.

meetup-host-{city}: states Category Moderator rights + Pretix
dashboard scope, requires 2FA + Community Engineering lead approval.

meetup-attendee-{city}: states notification subscription purpose,
notes membership is hidden for location privacy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: footer links, Pretix server docs, and static pages guidance

Provisioning:
- Set imprint_url (Code of Conduct) and privacy_url (Privacy Policy)
  on the Pretix organizer via settings API
- Set organizer_homepage_text with welcome message linking to forum
- Add constants: CODE_OF_CONDUCT_URL, PRIVACY_POLICY_URL, EVENTS_FORUM_URL

Engineering guide:
- Add "Pretix Server Configuration" section documenting:
  - pip install pretix-passbook pretix-pages
  - Full pretix.cfg [discourse_auth] section with all keys explained
  - Suggested pretix-pages static pages (about, FAQ)
  - Apple/Google Wallet certificate setup

FUTURE_WORK.md:
- Update share plugin (item 21) with verified signal names
- Add wallet passes (item 22) and check-in codes (item 23)
- Mark run_as impersonation as resolved

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: add pretix-pages content and template plugin config

Add pages/ directory with version-controlled content for Pretix
static pages (cloned from template to every new event):
- pages/about.md: links back to forum, explains the system
- pages/faq.md: attendee questions (accounts, RSVP, cancellation,
  QR codes, talk proposals, privacy)

Add pretix_pages and pretix_passbook to TEMPLATE_PLUGINS so the
template event enables them on creation.

Update engineering guide with one-time manual setup instructions
for adding pages to the template via Pretix admin UI. Note that
CoC and Privacy are handled by organizer-level imprint_url and
privacy_url settings (not pretix-pages).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

refactor: remove pretix-pages dependency

Footer links (CoC, Privacy Policy) are already handled by the
organizer-level imprint_url and privacy_url settings. Event content
is synced from the forum. The organizer homepage has a welcome
message. pretix-pages added a manual setup step and duplicate
content for no additional value.

Remove: pages/ directory, pretix_pages from TEMPLATE_PLUGINS,
pretix-pages from pip requirements, static pages setup docs.
Keep: pretix-passbook for Apple/Google Wallet passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: branding, wallet pass forum link, forum post improvements

Branding:
- Set primary_color to Ansible red (#EE0000) on the Pretix organizer
  for consistent brand presentation across all event pages

Wallet passes:
- Set pretix_passbook_backfield on every event item with the forum
  topic URL, so Apple/Google Wallet passes link attendees back to
  the forum discussion

Forum post template:
- Add Accessibility section stub for organisers to fill in (venue
  access, transport, dietary options)
- Add footer links to all Ansible meetups and Code of Conduct
- Improve discoverability by linking to the Events category

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

refactor: hardcode Discourse URL and simplify configuration

DISCOURSE_URL and DISCOURSE_API_USER are now hardcoded constants
(https://forum.ansible.com, system) — they never change across
deployments and the forum URL was already hardcoded in forum post
templates, social media copy, CoC/privacy links, and events URL.

Derive PRIVACY_POLICY_URL and EVENTS_FORUM_URL from DISCOURSE_URL
to eliminate duplication.

Only 3 env vars remain:
- PRETIX_URL (differs per deployment)
- PRETIX_API_TOKEN (secret)
- DISCOURSE_API_KEY (secret)

Reorganise constants into logical groups with section comments.
Update engineering guide to remove stale env var instructions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: add migration tracking group and invite link documentation

Add MIGRATED_GROUP_NAME constant (meetup-attendee-migrated) and
create the group in provision_environment.py with visibility_level 3
(hidden). Used to track which users migrated from Meetup Pro.

Add "Migrating from Meetup Pro" section to engineering guide:
- Step-by-step for creating Discourse invite links per city
- Invite links auto-add users to meetup-host-{city} or
  meetup-attendee-{city} PLUS meetup-attendee-migrated
- Migration progress tracking via group member count
- Separate organiser vs attendee invite workflows

Invite link creation is a manual step (Discourse admin UI) — not
automated, because each city migrates on its own timeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs: fix migration invite link settings

Invite links: max 5,000 uses, never expire, add to attendee groups
only (meetup-attendee-{city} + meetup-attendee-migrated). Explicitly
note NOT to add migrating attendees to meetup-host-{city}.

Organiser onboarding is a separate manual process requiring
Community Engineering lead approval + 2FA.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: Meetup Pro export scripts and per-city win in forum draft

Add meetup_list_groups.py: lists all groups in the Ansible Pro Network
with name, urlname, city, country, member count, and timezone. Outputs
CSV to stdout. Uses JWT OAuth2 + GraphQL (same auth as sync_meetups.py).

Add meetup_list_organisers.py: lists all organisers (organizer,
co-organizer, assistant, event organizer) for every group in the Pro
Network. Outputs CSV with group_name, group_urlname, role, full_name,
email, member_since. Handles pagination and rate limiting.

Forum draft: add "Per-city flexibility" to the "Why" section — highlight
that Meetup Pro cost forced consolidation (e.g., 11 Indian meetup groups
into one), which we can now reverse with no per-group cost.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

refactor: extract common Meetup API code to meetup_pro_lib.py

Move shared auth, GraphQL client, and constants out of the two export
scripts into meetup_pro_lib.py:
- pre_flight_checks() validates all 4 env vars + key file exists
- get_access_token() handles JWT signing + OAuth2 token exchange
- graphql() handles request + 429 rate limit retry
- PRO_NETWORK, MEETUP_API_URL, MEETUP_TOKEN_URL constants

Both scripts now import from the shared lib instead of duplicating
~70 lines of auth and HTTP code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

chore: gitignore

chore: use glob in lint.sh, fix lint issues in meetup scripts

Replace hardcoded file list in lint.sh with scripts/*.py glob so new
scripts are automatically included.

Add per-file ruff ignores for meetup scripts:
- meetup_pro_lib.py: S105 false positive on MEETUP_TOKEN_URL
- meetup_list_organisers.py: E501 for long GraphQL query line

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs: deep review — 9 new FUTURE_WORK items, persona analysis

Add items 24-32 from comprehensive attendance/persona review:

High impact:
- #24 RSVP count on forum post (social proof)
- #26 Enable GitHub/Google social login on Discourse (reduce signup
  friction — biggest conversion barrier for first-time attendees)
- #28 Auto-watch city subcategory for attendee groups (Discourse
  notification config)
- #29 Expanded email reminder spec (5-stage: T-7, T-3, T-1, T-0, T+1)

Medium impact:
- #25 Luma.com cross-listing as discovery channel (calendar re-sharing
  for organic reach via related communities)
- #27 New organiser onboarding DM (auto-send guide + 2FA reminder)

Low impact:
- #30 Sponsor acknowledgement section in event template
- #31 Speaker proposal template (structured format)
- #32 UTM tracking in social media copy

Also: add pretix.plugins.badges to TEMPLATE_PLUGINS (name badge
generation at check-in). Update forum draft with per-city flexibility
win (India consolidation reversal).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: SEO-friendly category descriptions for event discovery

Add description text to city subcategories with keywords:
"Ansible Community Meetup {city}", "free, in-person meetups",
"automation enthusiasts", "RSVP". This text appears in Discourse
category listings and is indexed by search engines.

The forum category (forum.ansible.com/c/events/8) already ranks
on page 1 for "Ansible Meetup Barcelona". Per-city subcategory
descriptions will help individual city pages rank for city-specific
searches like "Ansible Meetup London".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs: add SEO structured data and migration process items

Add item #33: Schema.org/Event structured data with three
implementation options (upstream Discourse Events plugin PR,
theme component, Pretix-side). Include exact JSON-LD format
and modifier hook details from Discourse source analysis.

Add item #34: SEO process actions for Meetup Pro migration
(pin notices on meetup.com, archive GitHub repos, update
ansible.com links, submit sitemap).

Based on analysis of forum.ansible.com HTML: OG/Twitter meta
tags are solid, but no schema.org/Event or JSON-LD exists.
Discourse core uses DiscussionForumPosting for all topics;
the Events plugin doesn't override this via the available
:topic_crawler_container_schema modifier hook.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs: rewrite schema.org/Event item with verified research

Rewrite item #33 based on analysis of:
- Live forum HTML (verified OG/Twitter meta tags present)
- Discourse crawler layout source (theme head_tag IS rendered)
- Discourse Events plugin source (no schema modifier registered,
  event data in TopicEventStartsAt/EndsAt custom fields)
- meta.discourse.org (no existing Event schema discussion, Rich
  JSON-LD plugin has it on roadmap)

Key finding: client-side theme component JS won't be visible in
the server-rendered crawler view. Upstream PR to the Events plugin
(using :topic_crawler_container_schema modifier + server:before-
head-close-crawler hook) is the correct long-term path.

Updated recommendation: upstream PR first, theme component
investigation second, existing OG tags are solid fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: SEO — noindex Pretix pages, validated schema.org research

Set meta_noindex: true on the Pretix template so event checkout
pages aren't indexed by search engines. The forum topic is the
content page that should rank; the Pretix page is a checkout flow.

Update FUTURE_WORK item #33 with validated research:
- Google Event Rich Results require name + startDate + physical
  location (virtual-only events not eligible for carousel)
- JavaScript-injected JSON-LD works but has rendering delays;
  server-side is recommended
- Pretix has no built-in canonical URL setting; html_head signal
  is the plugin injection point; meta_noindex is the pragmatic fix
- Discourse Events plugin does not use the existing
  :topic_crawler_container_schema modifier — upstream PR needed
- Fedora's calendar also has no schema.org/Event markup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

chore: add comment explaining meta_noindex setting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: implement schema.org/Event theme component for Discourse

Create a Discourse theme component that injects schema.org/Event
JSON-LD structured data on forum topics with event widgets.

How it works:
- Detects the .discourse-post-event div (server-rendered, contains
  data-start, data-end, data-timezone, data-name attributes)
- Constructs JSON-LD with @type: Event, startDate, endDate, name
- Injects <script type="application/ld+json"> into <head>
- Sets forum topic URL as canonical event URL
- Marks as free (isAccessibleForFree: true)
- Removes JSON-LD on non-event page navigation

Verified: forum.ansible.com serves identical HTML to all user
agents (no separate crawler view), so Googlebot will render
the JS and see the JSON-LD.

Limitation: no structured location/address data in the event
widget, so Google's event carousel (which requires a physical
address) may not activate. Still valuable for AI assistants
and other schema.org consumers.

Files:
- discourse-theme/event-jsonld/about.json (component metadata)
- discourse-theme/event-jsonld/common/head_tag.html (the code)
- GUIDE_ENGINEERING.md: installation + verification instructions
- FUTURE_WORK.md: updated status to implemented

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs: expand theme component installation with testing steps

Add three-step testing procedure:
1. Browser DevTools console check (immediate validation)
2. Google Rich Results Test (renders JS, validates schema)
3. Schema Markup Validator (optional, detailed report)

Clarify: no rebuild needed for theme components (unlike plugins).
Add troubleshooting steps if structured data is not detected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: embed schema.org/Event JSON-LD in forum posts with venue data

Add --venue and --address optional CLI args to create_event.py.
When provided, the forum post template shows the actual venue
instead of "To be announced", and a schema.org/Event JSON-LD
block is embedded directly in the post markdown with:
- @type: Event with name, startDate, endDate
- location with @type: Place, name, PostalAddress
- offers with price: 0, RSVP URL pointing to Pretix
- url pointing to the forum topic (canonical)
- isAccessibleForFree: true

The JSON-LD is updated in the second pass (after Pretix URL is
known) to include the forum URL and Pretix RSVP link.

This is server-rendered HTML in the post content — no JavaScript
dependency, visible to all crawlers immediately. With a physical
address, Google's Event Rich Results carousel can activate.

FUTURE_WORK.md:
- #35: Remove all meetup.com/pro/ansible links (audit of known
  locations: ansible.com, docs, GitHub repos, Meetup.com groups)
- #36: Events category page "About" pinned topic
- #37: Tiered SEO impact plan (Tier 1/2/3 by real-world impact)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: richer forum post template with speakers, directions, what to bring

Forum post template additions:
- Speakers section with organiser bio stub and HTML comment template
  for adding confirmed speakers with @username, bio, and talk title
- "What to Bring" section (laptop optional, questions, bring a friend)
- "Getting There" stub for organisers to add directions/transport
- Use EVENTS_FORUM_URL and CODE_OF_CONDUCT_URL constants in footer
  links (was hardcoded)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs: add 4 new FUTURE_WORK items from deep review

- #38 LinkedIn Events cross-posting (native events via API or manual)
- #39 Dynamic speaker social cards (branded image generation)
- #40 First-timer welcoming protocol (Pretix checkout question +
  stickers for name badges — near-zero code, high retention impact)
- #41 Video recording & archiving (YouTube + Discourse embed)

Items from user research that overlap with existing items were
merged into the existing entries rather than duplicated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs: rewrite FUTURE_WORK.md with consistent structure and ROI

Complete rewrite: 907 lines → 722 lines (21% reduction).

Structure changes:
- Group by lifecycle stage (Discovery, Signup, Pre-Event, At Event,
  Post-Event, Organiser, Infrastructure) instead of arbitrary numbering
- Every item has consistent subheadings: Description, Status, ROI
  (with justification), Effort, Type, Technical Implementation
- Merged duplicates: email reminders, SEO items, check-in items

ROI assessment:
- 7 HIGH (measurable attendance/discovery impact with evidence)
- 15 MEDIUM (indirect or harder to measure)
- 13 LOW (nice-to-have, minimal measurable impact)
- Each ROI includes a one-sentence justification, not just the label

Status updates:
- Schema.org/Event: Implemented
- Social media copy: Implemented
- meta_noindex: Implemented
- Category descriptions: Implemented
- Organiser validation: Implemented
- Passbook back field: Implemented

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

feat: user field city sync and group reconciliation

Add USER_CITY_FIELD_NAME and USER_CITY_FIELD_ID constants to lib
for the Discourse user_event_cities custom field (#4).

provision_environment.py:
- New ensure_user_field_options() adds missing city field_value
  options to user field #4 (additive only, never removes). Runs
  on every provision to keep options in sync with CITIES registry.

sync_city_groups.py (new):
- Full-state reconciliation of meetup-attendee-* group membership
  from user_event_cities field values.
- Maps "Europe:UK:London" → meetup-attendee-london group.
- Designed for hourly/daily cron execution.
- --dry-run (default, shows plan) / --apply (makes changes)
- --prune (optional, removes users who deselected a city)
- Fails gracefully: unknown field values or missing groups skipped.
- Uses ansible_events_lib for constants, auth, and pre_flight_checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

fix: use underscored path for user field PUT endpoint

Discourse routes: GET uses hyphenated path (user-fields/{id}) via
explicit route, but PUT/PATCH uses underscored path (user_fields/{id})
via the resources declaration. The GET worked, PUT returned 404.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

fix: add logging for each group/category being provisioned

Log the name of each group and category before the API call so
operators can see which 422 (already exists) responses are expected
vs unexpected. Fixes confusing output where 422s appeared without
context about what was being created.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

init

refactor: hardcode Discourse URL and constants in SSO toolkit

DISCOURSE_BASE_URL, API_USER, ENFORCE_2FA, HOST_PREFIX, MEETUP_KEYWORD
are now hardcoded — they never change across deployments and having
them as env vars created a false impression of configurability.

Remaining env vars (secrets/per-deployment):
- DISCOURSE_SSO_SECRET (required)
- DISCOURSE_API_KEY (optional, for enrichment)
- CALLBACK_URL (default: localhost:5000)
- FLASK_SECRET (default: dev-only key)
- REQUIRE_2FA (debugging toggle)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

chore: better examples
Comment thread SPECIFICATION.md Outdated
| `CALLBACK_URL` | SSO toolkit only | `http://localhost:5000/callback` | SSO return URL |
| `FLASK_SECRET` | SSO toolkit only | `dev-only-secret-change-me-0123456789` | Flask session signing key |
| `DISCOURSE_API_USERNAME` | SSO toolkit only | `system` | Admin user for enrichment queries |
| `REQUIRE_2FA` | SSO toolkit only | `0` | Send `require_2fa=true` in SSO payload |

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likely remove

Comment thread SPECIFICATION.md Outdated
| `DISCOURSE_API_USERNAME` | SSO toolkit only | `system` | Admin user for enrichment queries |
| `REQUIRE_2FA` | SSO toolkit only | `0` | Send `require_2fa=true` in SSO payload |
| `ENFORCE_2FA_FOR_PRIVILEGED` | SSO toolkit only | `1` | Block privileged users without 2FA |
| `HOST_GROUP_PREFIX` | SSO toolkit only | `meetup-host` | Prefix for host group detection |

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcode

Comment thread scripts/provision_environment.py Outdated
)


def ensure_discourse_groups(city_slug: str, city_name: str) -> tuple[str, str]:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to error if group doesn't match ^meetup-organisers-[a-z]+$

ie, we'd use:

  • meetup-organisers-sanfrancisco
  • meetup-organisers-saopaulo

Comment thread scripts/provision_environment.py Outdated
)


def main() -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-order this function to increase readability:

  • pre_flight_checks
  • Prefix global (ie not related to groups or cities_
  • Prefix Groups
  • Prefix Templates
  • Forum Groups
  • Forum sub-categories

etc

Comment thread scripts/ansible_events_lib.py Outdated
HOST_GROUP_PREFIX = "meetup-host"
ATTENDEE_GROUP_PREFIX = "meetup-attendee"
ADMIN_GROUP_NAME = "meetup-admin"
MIGRATED_GROUP_NAME = "meetup-attendee-migrated"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be confused with a city called migrated

Bikeshead: migrated, migrated-from-meetup-pro?

Suggested change
MIGRATED_GROUP_NAME = "meetup-attendee-migrated"
MIGRATED_GROUP_NAME = "meetup-migrated-attendees"

Comment thread scripts/create_event.py Outdated
parser.add_argument("--address", default=None, help="Venue address (e.g., '1 Southampton Row, London WC1B 5HA')")
args = parser.parse_args()

if not re.match(r"^[A-Za-z \-]+$", args.city):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need a single place in ansible_events_lib.py where city regex ([a-z]+) is defined, lowercase, no dash/numbers (match in ansible-community/meetup-pretix-discourse-auth#1)

Comment thread scripts/create_event.py Outdated
logger.error("Invalid city name %r: only letters, spaces, and hyphens allowed", args.city)
raise SystemExit(1)

city_info = get_city(args.city)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cities MUST be defined before use.

Suggested change
city_info = get_city(args.city)
city = get_city(args.city)
if city is None:
parser.error(f"Unknown city {args.city!r}, define in scripts/ansible_events_lib.py")

Comment thread scripts/provision_environment.py Outdated
@@ -0,0 +1,323 @@
#!/usr/bin/env python3

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Provisioning is described as idempotent, but it is mostly “create if absent”, not reconciliation

This is particularly important for Group visability.

Refactor to be reconciler:

Consequently, if you later change:

  • group visibility;
  • group descriptions;
  • category permissions;
  • category moderator assignments;
  • Pretix organiser permissions; or
  • another security-sensitive setting,
    rerunning provisioning does not necessarily enforce the new desired state.
    That's configuration drift, and for permissions it becomes a security problem.
    I suggest treating this script as a reconciler:
  1. Resolve resource by stable name.
  2. Create if absent.
  3. Compare security-relevant properties with desired state.
  4. PATCH differences.
  5. Fail if the object cannot be reconciled.

Comment thread scripts/ansible_events_lib.py Outdated
return results


def check_event_exists(slug: str) -> bool:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create_event.py interprets False as "the event definitely doesn't exist" and proceeds to create the Discourse topic.

Should update this (and simialr) to return enum/result:

  • EXISTS
  • DOES_NOT_EXIST
  • UNKNOWN / API_FAILURE

Comment thread scripts/ansible_events_lib.py Outdated
return any(g.get("name", "").lower() == group_name.lower() for g in groups)


def pre_flight_checks(*, require_discourse: bool = True) -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fail is host doesn't start with ^https:// (unless ^http://localhost)

@dbrennand dbrennand left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @gundalow for working on this. I've added some comments below for your review, but here are my overall observations:

  1. Google docstrings are not used throughout, with some functions having no docstring at all.
  2. Mixture of HTTP libraries. ansible_events_lib.py uses httpx, then discorse-connect-mvp.py uses urllib and sync_city_groups.py uses requests - We should use one and stick to that throughout the entire project.

Comment thread .github/workflows/ci.yml Outdated
Comment on lines +27 to +29
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not required when we're using astral-sh/setup-uv which has a python-version input.

Comment thread .github/workflows/ci.yml Outdated
Comment on lines +48 to +50
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

Comment thread discourse-theme/event-jsonld/about.json Outdated
@@ -0,0 +1,5 @@
{
"name": "Event JSON-LD Structured Data",
"about_url": "https://github.com/ansible-community/discourse-pretix-luma",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This URL is a 404.

Comment thread scripts/ansible_events_lib.py Outdated
from urllib.parse import urljoin, urlparse
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

import httpx

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use httpx2 (a maintained fork from Pydantic): https://pydantic.dev/docs/httpx2/get-started/migration/

Simple usage is import httpx2 as httpx

Comment thread scripts/ansible_events_lib.py Outdated
CITIES: tuple[CityInfo, ...] = (
CityInfo(region="Europe", country="UK", slug="london", city="London", timezone="Europe/London"),
CityInfo(region="Europe", country="Spain", slug="barcelona", city="Barcelona", timezone="Europe/Madrid"),
CityInfo(region="Europe", country="UK", slug="faketown", city="FakeTown", timezone="Europe/London"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need FakeTown?

Comment thread scripts/discourse-connect-mvp.py Outdated
"""Read-only Discourse API call; returns dict or None. Never blocks the flow."""
if not API_KEY:
return None
req = urllib.request.Request(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're using httpx in ansible_events_lib.py - Should we keep it consistent throughout?

Comment thread scripts/discourse-connect-mvp.py Outdated
print(f"Callback : {CALLBACK_URL}")
print(f"API key : {'SET' if API_KEY else 'NOT set (2FA/status checks show unknown)'}")
print(f"REQUIRE_2FA (challenge mode): {REQUIRE_2FA}")
app.run(host="127.0.0.1", port=5000, debug=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

host= needs to be configurable.

Comment thread scripts/discourse-connect-mvp.py Outdated
Comment on lines +77 to +85
def build_sso_url(nonce: str) -> str:
fields = {"nonce": nonce, "return_sso_url": CALLBACK_URL}
if REQUIRE_2FA:
fields["require_2fa"] = "true"
raw = urllib.parse.urlencode(fields)
b64 = base64.b64encode(raw.encode("utf-8"))
sig = sign(b64)
sso = urllib.parse.quote(b64.decode("ascii"))
return f"{DISCOURSE_BASE_URL}/session/sso_provider?sso={sso}&sig={sig}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use httpx.QueryParams and httpx.URL here to drop usage of urllib?

https://www.encode.io/httpnext/urls/#query-parameters

httpx.URL("https://example.com/", params={"search": "some text"})
def build_sso_url(nonce: str) -> str:
    fields = {"nonce": nonce, "return_sso_url": CALLBACK_URL}
    if REQUIRE_2FA:
        fields["require_2fa"] = "true"

    # Encodes dictionary parameters into a query string ('nonce=...&return_sso_url=...')
    raw = str(httpx.QueryParams(fields))

    b64 = base64.b64encode(raw.encode("utf-8"))
    sig = sign(b64)

    # Constructs the full URL and automatically percent-encodes the query values
    url = httpx.URL(
        f"{DISCOURSE_BASE_URL}/session/sso_provider",
        params={"sso": b64.decode("ascii"), "sig": sig},
    )

    return str(url)

Comment thread scripts/sync_city_groups.py Outdated
Comment on lines +45 to +46
def _headers() -> dict[str, str | None]:
return {"Api-Key": DISCOURSE_API_KEY, "Api-Username": DISCOURSE_API_USER, "Accept": "application/json"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of calling this function multiple times, we should use httpx.Client to set the headers, timeout and base URL once, then all subsequent requests using the client will have that configuration.

>>> with httpx.Client(base_url=DISCOURSE_URL, timeout=API_REQUEST_TIMEOUT_SECONDS, headers={}) as client:
...     r = client.get(f'/{path}')

Comment thread main.py Outdated

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think we can remove this file, likely autogenerated by uv init?

@gundalow

Copy link
Copy Markdown
Contributor Author

Moved into ansible-community/meetup#20

@gundalow gundalow closed this Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants