Repository navigation
Conversation
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
e2acfa0 to
b0373ee
Compare
| | `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 | |
| | `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 | |
| ) | ||
|
|
||
|
|
||
| def ensure_discourse_groups(city_slug: str, city_name: str) -> tuple[str, str]: |
There was a problem hiding this comment.
Need to error if group doesn't match ^meetup-organisers-[a-z]+$
ie, we'd use:
- meetup-organisers-sanfrancisco
- meetup-organisers-saopaulo
| ) | ||
|
|
||
|
|
||
| def main() -> None: |
There was a problem hiding this comment.
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
| HOST_GROUP_PREFIX = "meetup-host" | ||
| ATTENDEE_GROUP_PREFIX = "meetup-attendee" | ||
| ADMIN_GROUP_NAME = "meetup-admin" | ||
| MIGRATED_GROUP_NAME = "meetup-attendee-migrated" |
There was a problem hiding this comment.
This could be confused with a city called migrated
Bikeshead: migrated, migrated-from-meetup-pro?
| MIGRATED_GROUP_NAME = "meetup-attendee-migrated" | |
| MIGRATED_GROUP_NAME = "meetup-migrated-attendees" |
| 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): |
There was a problem hiding this comment.
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)
| logger.error("Invalid city name %r: only letters, spaces, and hyphens allowed", args.city) | ||
| raise SystemExit(1) | ||
|
|
||
| city_info = get_city(args.city) |
There was a problem hiding this comment.
Cities MUST be defined before use.
| 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") |
| @@ -0,0 +1,323 @@ | |||
| #!/usr/bin/env python3 | |||
There was a problem hiding this comment.
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:
- Resolve resource by stable name.
- Create if absent.
- Compare security-relevant properties with desired state.
- PATCH differences.
- Fail if the object cannot be reconciled.
| return results | ||
|
|
||
|
|
||
| def check_event_exists(slug: str) -> bool: |
There was a problem hiding this comment.
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
| return any(g.get("name", "").lower() == group_name.lower() for g in groups) | ||
|
|
||
|
|
||
| def pre_flight_checks(*, require_discourse: bool = True) -> None: |
There was a problem hiding this comment.
Fail is host doesn't start with ^https:// (unless ^http://localhost)
dbrennand
left a comment
There was a problem hiding this comment.
Thanks @gundalow for working on this. I've added some comments below for your review, but here are my overall observations:
- Google docstrings are not used throughout, with some functions having no docstring at all.
- Mixture of HTTP libraries.
ansible_events_lib.pyuses httpx, thendiscorse-connect-mvp.pyusesurllibandsync_city_groups.pyusesrequests- We should use one and stick to that throughout the entire project.
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | ||
| with: | ||
| python-version: ${{ matrix.python-version }} |
There was a problem hiding this comment.
Not required when we're using astral-sh/setup-uv which has a python-version input.
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | ||
| with: | ||
| python-version: "3.13" |
| @@ -0,0 +1,5 @@ | |||
| { | |||
| "name": "Event JSON-LD Structured Data", | |||
| "about_url": "https://github.com/ansible-community/discourse-pretix-luma", | |||
| from urllib.parse import urljoin, urlparse | ||
| from zoneinfo import ZoneInfo, ZoneInfoNotFoundError | ||
|
|
||
| import httpx |
There was a problem hiding this comment.
Use httpx2 (a maintained fork from Pydantic): https://pydantic.dev/docs/httpx2/get-started/migration/
Simple usage is import httpx2 as httpx
| 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"), |
| """Read-only Discourse API call; returns dict or None. Never blocks the flow.""" | ||
| if not API_KEY: | ||
| return None | ||
| req = urllib.request.Request( |
There was a problem hiding this comment.
We're using httpx in ansible_events_lib.py - Should we keep it consistent throughout?
| 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) |
| 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}" |
There was a problem hiding this comment.
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)
| def _headers() -> dict[str, str | None]: | ||
| return {"Api-Key": DISCOURSE_API_KEY, "Api-Username": DISCOURSE_API_USER, "Accept": "application/json"} |
There was a problem hiding this comment.
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}')
There was a problem hiding this comment.
Think we can remove this file, likely autogenerated by uv init?
|
Moved into ansible-community/meetup#20 |
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
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.Ansible Meetup Staffteam 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; Pretixis_staffremains under Pretix administrator control.Main components
scripts/ansible_events_lib.pydefines explicit city slugs and canonical display names, strict API clients, pagination, and shared policy constants.scripts/provision_environment.pyprovisions Forum groups/categories and Pretix templates/teams to their desired state.scripts/create_event.pyvalidates 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.pysynchronizes the canonical Forum topic content to Pretix.scripts/sync_city_groups.pyreconciles profile-field subscriptions with hidden attendee groups.scripts/discourse-connect-mvp.pyis 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.