diff --git a/README.md b/README.md
index c44abc8279..6d5e04c7bb 100644
--- a/README.md
+++ b/README.md
@@ -29,8 +29,10 @@ You will find the services at the following ports:
Everything you need to get started is already configured
and will work out of the box.
-If you need to work with our Stripe or Pretix integration, you will have to ask
-on Slack which secret key you need and why you need it.
+If you need to work with Stripe or the hosted Pretix instance, ask on Slack
+which secret key you need and why you need it. Local Pretix development does
+not require shared credentials; see
+[`backend/dashboard/README.md`](backend/dashboard/README.md#local-setup).
Once given, create a `.env` file at the project root with inside:
diff --git a/backend/dashboard/README.md b/backend/dashboard/README.md
new file mode 100644
index 0000000000..7f703c0abf
--- /dev/null
+++ b/backend/dashboard/README.md
@@ -0,0 +1,91 @@
+# Dashboard data sources
+
+The dashboard always reads conference, proposal, decision, grant, and allocated
+budget data from the configured Django database.
+
+Ticket totals, gross ticket revenue, completed refunds, product mix, and the
+eight-week sales chart are calculated from Pretix's read-only Items and Orders
+APIs.
+
+## Local setup
+
+The default Docker configuration points the backend at an isolated local Pretix
+API. Pretix is optional and lives behind the `pretix` Compose profile, so it
+does not add startup time to unrelated development work.
+
+To start Pretix without adding staged data:
+
+```sh
+docker compose --profile pretix up -d --wait pretix
+```
+
+The control panel is at . The local-only login is
+`admin@localhost` / `admin` after running the stage seeder below.
+
+### Dashboard stages
+
+Run the disposable stage seeder from the repository root:
+
+```sh
+./scripts/seed-dashboard-stage cfp-open
+```
+
+Available stages are:
+
+- `cfp-open`
+- `review`
+- `ticket-sales`
+- `conference-week`
+- `post-event`
+
+Each run replaces only the `dashboard-local` conference and the matching
+`python-italia-local/dashboard-local` Pretix event. It creates deterministic
+proposal, vote, grant, reimbursement, product, order, and ticket histories for
+the selected point in time. Open the result at
+. The seeded Pretix shop is at
+.
+
+The seeder refuses to run when Django is not in debug mode or when `PRETIX_API`
+does not resolve to `pretix`, `localhost`, or `127.0.0.1`. Local Pretix uses its
+own PostgreSQL database and Docker volumes. Stop it with:
+
+```sh
+docker compose --profile pretix stop pretix pretix-db
+```
+
+Set `LOCAL_PRETIX_API` and `LOCAL_PRETIX_API_TOKEN` only when deliberately
+testing a different development instance. The existing `PRETIX_API_TOKEN` in
+`.env` is not used by the Compose backend, which prevents a local stage run from
+accidentally authenticating to hosted Pretix.
+
+## Production behavior
+
+Production already provides `PRETIX_API` and `PRETIX_API_TOKEN`. The response is
+reduced to aggregate values in memory: order codes, attendee details, email
+addresses, and other personal data are never cached or sent to the browser.
+
+Pretix aggregates are cached for 15 minutes. API failures render a safe
+unavailable state instead of failing the whole dashboard, and that unavailable
+state is cached briefly so a Pretix outage cannot create a request storm.
+
+The production dashboard is available at
+ and requires a staff account. Comparisons are
+limited to two additional conferences to bound cold-cache Pretix and database
+work. Relevant settings are:
+
+```text
+DASHBOARD_REQUIRE_STAFF=true
+DASHBOARD_MAX_COMPARISON_CONFERENCES=2
+DASHBOARD_PRETIX_CACHE_TIMEOUT=900
+DASHBOARD_PRETIX_ERROR_CACHE_TIMEOUT=60
+PRETIX_API_TIMEOUT=10
+PRETIX_API_HOST=
+```
+
+Use a least-privilege Pretix token with read access only to the required
+organizer/events and the Items and Orders resources.
+
+Before enabling a release, verify the dashboard build check, the focused
+dashboard/Pretix tests, and a staff login against the deployed `/dashboard` URL.
+Ticket analytics may intentionally show as unavailable while Pretix is
+unreachable; proposal and grant analytics should continue to render.
diff --git a/backend/dashboard/dev/seed_stage.py b/backend/dashboard/dev/seed_stage.py
new file mode 100644
index 0000000000..32e4243bcd
--- /dev/null
+++ b/backend/dashboard/dev/seed_stage.py
@@ -0,0 +1,564 @@
+"""Seed the local dashboard and Pretix with deterministic stage data.
+
+This file is executed through ``manage.py shell`` by scripts/seed-dashboard-stage.
+It is deliberately guarded so it cannot target a hosted Pretix instance.
+"""
+
+import math
+import os
+from datetime import UTC, datetime, time, timedelta
+from decimal import Decimal
+from urllib.parse import urlparse
+
+import requests
+from django.conf import settings
+from django.core.cache import cache
+from django.db import transaction
+from django.utils import timezone
+
+from conferences.models import AudienceLevel, Conference, Deadline, Duration, Topic
+from grants.models import Grant, GrantReimbursement, GrantReimbursementCategory
+from i18n.strings import LazyI18nString
+from languages.models import Language
+from organizers.models import Organizer
+from reviews.models import UserReview
+from submissions.models import Submission, SubmissionType
+from users.models import User
+from voting.models import Vote
+
+CONFERENCE_CODE = "dashboard-local"
+ORGANIZER_SLUG = "python-italia-local"
+
+STAGES = {
+ "cfp-open": {
+ "conference_offset": 180,
+ "deadlines": {
+ "cfp": (-14, 28),
+ "voting": (35, 49),
+ "grants": (-7, 42),
+ "custom": (84, 90),
+ },
+ "proposals": {"proposed": 54},
+ "grants": {"pending": 12},
+ "votes": 0,
+ "tickets": 0,
+ "active_weeks": 3,
+ },
+ "review": {
+ "conference_offset": 120,
+ "deadlines": {
+ "cfp": (-70, -28),
+ "voting": (-7, 14),
+ "grants": (-35, 14),
+ "custom": (28, 35),
+ },
+ "proposals": {"proposed": 186},
+ "grants": {"pending": 48},
+ "votes": 1260,
+ "tickets": 80,
+ "active_weeks": 8,
+ },
+ "ticket-sales": {
+ "conference_offset": 60,
+ "deadlines": {
+ "cfp": (-120, -80),
+ "voting": (-70, -50),
+ "grants": (-95, -45),
+ "custom": (-35, -28),
+ },
+ "proposals": {
+ "accepted": 42,
+ "waiting_list": 12,
+ "rejected": 151,
+ "cancelled": 9,
+ },
+ "grants": {
+ "approved": 10,
+ "waiting_for_confirmation": 8,
+ "confirmed": 20,
+ "waiting_list": 12,
+ "rejected": 26,
+ },
+ "votes": 1700,
+ "tickets": 480,
+ "active_weeks": 8,
+ },
+ "conference-week": {
+ "conference_offset": -1,
+ "deadlines": {
+ "cfp": (-230, -190),
+ "voting": (-170, -150),
+ "grants": (-205, -145),
+ "custom": (-105, -98),
+ },
+ "proposals": {
+ "accepted": 45,
+ "waiting_list": 8,
+ "rejected": 160,
+ "cancelled": 9,
+ },
+ "grants": {
+ "approved": 4,
+ "waiting_for_confirmation": 3,
+ "confirmed": 45,
+ "waiting_list": 8,
+ "refused": 4,
+ "rejected": 20,
+ },
+ "votes": 1850,
+ "tickets": 960,
+ "active_weeks": 8,
+ },
+ "post-event": {
+ "conference_offset": -35,
+ "deadlines": {
+ "cfp": (-265, -225),
+ "voting": (-205, -185),
+ "grants": (-240, -180),
+ "custom": (-140, -133),
+ },
+ "proposals": {
+ "accepted": 46,
+ "waiting_list": 7,
+ "rejected": 163,
+ "cancelled": 10,
+ },
+ "grants": {
+ "confirmed": 47,
+ "waiting_list": 6,
+ "refused": 6,
+ "rejected": 22,
+ "did_not_attend": 5,
+ },
+ "votes": 1900,
+ "tickets": 1040,
+ "active_weeks": 8,
+ },
+}
+
+PRODUCTS = (
+ {
+ "name": "Community ticket",
+ "internal_name": "community",
+ "price": "199.00",
+ },
+ {
+ "name": "Student ticket",
+ "internal_name": "student",
+ "price": "99.00",
+ },
+ {
+ "name": "Business ticket",
+ "internal_name": "business",
+ "price": "399.00",
+ },
+)
+
+
+def i18n(value: str) -> LazyI18nString:
+ return LazyI18nString({"en": value, "it": value})
+
+
+def expand_counts(counts: dict[str, int]) -> list[str]:
+ return [status for status, count in counts.items() for _ in range(count)]
+
+
+def spread_timestamps(objects: list, active_weeks: int) -> None:
+ if not objects:
+ return
+
+ current = timezone.now()
+ for index, item in enumerate(objects):
+ week = min(active_weeks - 1, index * active_weeks // len(objects))
+ weeks_ago = active_weeks - week - 1
+ item.created = current - timedelta(
+ weeks=weeks_ago,
+ days=(index * 3) % 6,
+ hours=index % 12,
+ )
+ item.modified = item.created
+ objects[0].__class__.objects.bulk_update(objects, ["created", "modified"])
+
+
+def create_users(prefix: str, count: int) -> list[User]:
+ users = [
+ User(
+ email=f"dashboard-{prefix}-{index + 1}@dashboard.local",
+ username=f"dashboard-{prefix}-{index + 1}",
+ full_name=f"Dashboard {prefix.title()} {index + 1}",
+ name=f"{prefix.title()} {index + 1}",
+ password="!",
+ )
+ for index in range(count)
+ ]
+ return User.objects.bulk_create(users)
+
+
+def seed_django(stage: dict) -> Conference:
+ today = timezone.localdate()
+ conference_start = timezone.make_aware(
+ datetime.combine(
+ today + timedelta(days=stage["conference_offset"]),
+ time(hour=9),
+ ),
+ timezone=timezone.get_current_timezone(),
+ )
+ conference_end = conference_start + timedelta(days=3, hours=9)
+
+ with transaction.atomic():
+ existing_conference = Conference.objects.filter(code=CONFERENCE_CODE).first()
+ if existing_conference is not None:
+ Submission.objects.filter(conference=existing_conference).delete()
+ UserReview.objects.filter(grant__conference=existing_conference).delete()
+ GrantReimbursement.objects.filter(
+ grant__conference=existing_conference
+ ).delete()
+ Grant.objects.filter(conference=existing_conference)._raw_delete(
+ using=Grant.objects.db
+ )
+ existing_conference.delete()
+ User.objects.filter(
+ email__startswith="dashboard-",
+ email__endswith="@dashboard.local",
+ ).delete()
+
+ organizer, _ = Organizer.objects.get_or_create(
+ slug=ORGANIZER_SLUG,
+ defaults={"name": "Python Italia local"},
+ )
+ conference = Conference.objects.create(
+ organizer=organizer,
+ name=i18n("PyCon Italia dashboard playground"),
+ introduction=i18n("Local-only data for dashboard development."),
+ code=CONFERENCE_CODE,
+ hostname="dashboard.localhost",
+ location="Bologna, Italy",
+ timezone="Europe/Rome",
+ start=conference_start,
+ end=conference_end,
+ pretix_organizer_id=ORGANIZER_SLUG,
+ pretix_event_id=CONFERENCE_CODE,
+ pretix_event_url=(
+ f"http://localhost:8345/{ORGANIZER_SLUG}/{CONFERENCE_CODE}/"
+ ),
+ )
+
+ deadline_names = {
+ "cfp": "Call for proposals",
+ "voting": "Community voting",
+ "grants": "Financial aid",
+ "custom": "Schedule announced",
+ }
+ for deadline_type, offsets in stage["deadlines"].items():
+ Deadline.objects.create(
+ conference=conference,
+ type=deadline_type,
+ name=i18n(deadline_names[deadline_type]),
+ description=i18n(f"Local {deadline_names[deadline_type]} stage."),
+ start=timezone.now() + timedelta(days=offsets[0]),
+ end=timezone.now() + timedelta(days=offsets[1]),
+ )
+
+ submission_type, _ = SubmissionType.objects.get_or_create(name="Dashboard talk")
+ audience_level, _ = AudienceLevel.objects.get_or_create(
+ name="Dashboard intermediate"
+ )
+ topic, _ = Topic.objects.get_or_create(name="Dashboard Python")
+ duration = Duration.objects.create(
+ conference=conference,
+ name="30 minutes",
+ duration=30,
+ notes="Local dashboard seed",
+ )
+ duration.allowed_submission_types.add(submission_type)
+ conference.submission_types.add(submission_type)
+ conference.audience_levels.add(audience_level)
+ conference.topics.add(topic)
+ languages = Language.objects.filter(code__in=("en", "it"))
+ conference.languages.add(*languages)
+
+ proposal_statuses = expand_counts(stage["proposals"])
+ speakers = create_users("speaker", len(proposal_statuses))
+ submissions = Submission.objects.bulk_create(
+ [
+ Submission(
+ conference=conference,
+ title=i18n(f"Practical Python story {index + 1}"),
+ abstract=i18n(
+ "A realistic local proposal used to exercise dashboard trends."
+ ),
+ elevator_pitch=i18n("Useful Python lessons from production."),
+ slug=f"dashboard-proposal-{index + 1}",
+ speaker_level=("new", "intermediate", "experienced")[index % 3],
+ previous_talk_video="",
+ speaker=speakers[index],
+ topic=topic,
+ type=submission_type,
+ duration=duration,
+ audience_level=audience_level,
+ status=status,
+ )
+ for index, status in enumerate(proposal_statuses)
+ ]
+ )
+ spread_timestamps(submissions, stage["active_weeks"])
+
+ grant_statuses = expand_counts(stage["grants"])
+ applicants = create_users("applicant", len(grant_statuses))
+ grants = Grant.objects.bulk_create(
+ [
+ Grant(
+ conference=conference,
+ user=applicants[index],
+ email=applicants[index].email,
+ full_name=f"Dashboard Applicant {index + 1}",
+ name=f"Applicant {index + 1}",
+ age_group=Grant.AgeGroup.range_25_34,
+ occupation=Grant.Occupation.developer,
+ grant_type=[Grant.GrantType.diversity],
+ departure_country="IT",
+ nationality="Italian",
+ departure_city="Bologna",
+ needs_funds_for_travel=index % 3 != 0,
+ need_accommodation=index % 2 == 0,
+ why="Attending would support my local Python community work.",
+ python_usage="I use Python for web and data projects.",
+ been_to_other_events="A mix of local meetups and conferences.",
+ status=status,
+ )
+ for index, status in enumerate(grant_statuses)
+ ]
+ )
+ spread_timestamps(grants, stage["active_weeks"])
+
+ ticket_category = GrantReimbursementCategory.objects.create(
+ conference=conference,
+ name="Ticket",
+ category=GrantReimbursementCategory.Category.TICKET,
+ max_amount=Decimal(199),
+ included_by_default=True,
+ )
+ travel_category = GrantReimbursementCategory.objects.create(
+ conference=conference,
+ name="Travel",
+ category=GrantReimbursementCategory.Category.TRAVEL,
+ max_amount=Decimal(700),
+ )
+ allocated_statuses = {
+ Grant.Status.approved,
+ Grant.Status.waiting_for_confirmation,
+ Grant.Status.confirmed,
+ }
+ reimbursements = []
+ for index, grant in enumerate(grants):
+ if grant.status not in allocated_statuses:
+ continue
+ reimbursements.append(
+ GrantReimbursement(
+ grant=grant,
+ category=ticket_category,
+ granted_amount=Decimal(199),
+ )
+ )
+ if index % 3 != 0:
+ reimbursements.append(
+ GrantReimbursement(
+ grant=grant,
+ category=travel_category,
+ granted_amount=Decimal(str(250 + (index % 5) * 75)),
+ )
+ )
+ GrantReimbursement.objects.bulk_create(reimbursements)
+
+ if stage["votes"]:
+ voter_count = math.ceil(stage["votes"] / len(submissions))
+ voters = create_users("voter", voter_count)
+ votes = [
+ Vote(
+ user=voters[index // len(submissions)],
+ submission=submissions[index % len(submissions)],
+ value=(index % 4) + 1,
+ )
+ for index in range(stage["votes"])
+ ]
+ Vote.objects.bulk_create(votes)
+
+ return conference
+
+
+class PretixAPI:
+ def __init__(self) -> None:
+ parsed = urlparse(settings.PRETIX_API)
+ if parsed.hostname not in {"pretix", "localhost", "127.0.0.1"}:
+ raise RuntimeError(
+ "The dashboard stage seeder refuses to target a non-local Pretix API."
+ )
+
+ self.base_url = settings.PRETIX_API.rstrip("/")
+ self.session = requests.Session()
+ self.session.headers.update(
+ {
+ "Authorization": f"Token {settings.PRETIX_API_TOKEN}",
+ "Host": settings.PRETIX_API_HOST or "localhost:8345",
+ }
+ )
+
+ def request(self, method: str, path: str, **kwargs) -> dict:
+ response = self.session.request(
+ method,
+ f"{self.base_url}/{path.lstrip('/')}",
+ timeout=settings.PRETIX_API_TIMEOUT,
+ **kwargs,
+ )
+ if not response.ok:
+ raise RuntimeError(
+ f"Pretix {method} {path} returned {response.status_code}: "
+ f"{response.text[:1000]}"
+ )
+ return response.json() if response.content else {}
+
+
+def order_week(index: int, total: int) -> int:
+ weights = (2, 4, 6, 9, 12, 16, 21, 30)
+ target = index * sum(weights) / max(total, 1)
+ cumulative = 0
+ for week, weight in enumerate(weights):
+ cumulative += weight
+ if target < cumulative:
+ return week
+ return len(weights) - 1
+
+
+def seed_pretix(stage: dict, conference: Conference) -> None:
+ api = PretixAPI()
+ organizer_path = f"organizers/{ORGANIZER_SLUG}"
+ event_path = f"{organizer_path}/events/{CONFERENCE_CODE}"
+ event = api.request(
+ "POST",
+ f"{organizer_path}/events/",
+ json={
+ "name": {"en": "PyCon Italia dashboard playground"},
+ "slug": CONFERENCE_CODE,
+ "live": False,
+ "testmode": False,
+ "currency": "EUR",
+ "date_from": conference.start.isoformat(),
+ "date_to": conference.end.isoformat(),
+ "is_public": False,
+ "location": {"en": "Bologna, Italy"},
+ "timezone": "Europe/Rome",
+ "plugins": [
+ "pretix.plugins.sendmail",
+ "pretix.plugins.statistics",
+ "pretix.plugins.banktransfer",
+ ],
+ },
+ )
+
+ items = []
+ for position, product in enumerate(PRODUCTS):
+ items.append(
+ api.request(
+ "POST",
+ f"{event_path}/items/",
+ json={
+ "name": {"en": product["name"]},
+ "internal_name": product["internal_name"],
+ "active": True,
+ "default_price": product["price"],
+ "admission": True,
+ "personalized": True,
+ "position": position,
+ },
+ )
+ )
+
+ api.request(
+ "POST",
+ f"{event_path}/quotas/",
+ json={
+ "name": "Conference capacity",
+ "size": max(250, stage["tickets"] + 250),
+ "items": [item["id"] for item in items],
+ "variations": [],
+ "closed": False,
+ "close_when_sold_out": False,
+ },
+ )
+ position_pattern = (2, 4, 3, 4)
+ remaining = stage["tickets"]
+ order_sizes = []
+ order_index = 0
+ while remaining:
+ size = min(position_pattern[order_index % len(position_pattern)], remaining)
+ order_sizes.append(size)
+ remaining -= size
+ order_index += 1
+
+ current_monday = timezone.localdate() - timedelta(
+ days=timezone.localdate().weekday()
+ )
+ ticket_index = 0
+ for index, size in enumerate(order_sizes):
+ week = order_week(index, len(order_sizes))
+ week_start = current_monday - timedelta(weeks=7 - week)
+ ordered_at = datetime.combine(
+ week_start + timedelta(days=(index * 3) % 7),
+ time(hour=10 + index % 8),
+ tzinfo=UTC,
+ )
+ positions = []
+ for _ in range(size):
+ roll = (ticket_index * 37) % 100
+ item_index = 0 if roll < 65 else 1 if roll < 85 else 2
+ positions.append(
+ {
+ "item": items[item_index]["id"],
+ "attendee_name": f"Local Attendee {ticket_index + 1}",
+ }
+ )
+ ticket_index += 1
+
+ api.request(
+ "POST",
+ f"{event_path}/orders/",
+ json={
+ "status": "p",
+ "testmode": False,
+ "locale": "en",
+ "payment_provider": "banktransfer",
+ "positions": positions,
+ "force": True,
+ "send_email": False,
+ "api_meta": {
+ "dashboard_seed_ordered_at": ordered_at.isoformat(),
+ },
+ },
+ )
+ if (index + 1) % 50 == 0:
+ print(f"Created {index + 1}/{len(order_sizes)} local Pretix orders...")
+
+ print(
+ f"Created Pretix event {event['slug']} with {stage['tickets']} paid tickets "
+ f"across {len(order_sizes)} orders."
+ )
+
+
+if not settings.DEBUG:
+ raise RuntimeError("Dashboard stage data can only be seeded with DEBUG enabled.")
+
+stage_name = os.environ.get("DASHBOARD_STAGE", "")
+if stage_name not in STAGES:
+ raise ValueError(
+ f"Unknown DASHBOARD_STAGE {stage_name!r}. Choose from: {', '.join(STAGES)}"
+ )
+
+selected_stage = STAGES[stage_name]
+seeded_conference = seed_django(selected_stage)
+seed_pretix(selected_stage, seeded_conference)
+cache.clear()
+print(
+ f"Seeded {stage_name}: {sum(selected_stage['proposals'].values())} proposals, "
+ f"{sum(selected_stage['grants'].values())} grants, "
+ f"{selected_stage['votes']} votes, and {selected_stage['tickets']} tickets."
+)
diff --git a/docker-compose.yml b/docker-compose.yml
index e0b7084b96..908a8346e4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,7 +4,6 @@ x-defaults:
NODE_ENV: "development"
ENV: "local"
CONFERENCE_CODE: ${CONFERENCE_CODE:-pycon2023}
- PRETIX_API: https://tickets.pycon.it/api/v1/
PLAIN_API: https://core-api.uk.plain.com/graphql/v1
SECRET_KEY: secret-key
API_URL: /graphql
@@ -26,6 +25,9 @@ x-defaults:
CELERY_RESULT_BACKEND: redis://redis:6379/10
CLAMAV_HOST: clamav
CLAMAV_PORT: 3310
+ PRETIX_API: ${LOCAL_PRETIX_API:-http://pretix/api/v1/}
+ PRETIX_API_HOST: ${LOCAL_PRETIX_API_HOST:-localhost:8345}
+ PRETIX_API_TOKEN: ${LOCAL_PRETIX_API_TOKEN:-local-dashboard-api-token}
services:
backend:
@@ -114,6 +116,71 @@ services:
interval: 5s
timeout: 5s
+ pretix:
+ profiles: [pretix]
+ build:
+ context: ./pretix
+ networks: [pycon_net]
+ restart: unless-stopped
+ depends_on:
+ pretix-db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ ports:
+ - "127.0.0.1:8345:80"
+ volumes:
+ - pycon-pretix-data:/data
+ environment:
+ DATA_DIR: /data
+ DJANGO_SETTINGS_MODULE: production_settings
+ PRETIX_CELERY_BACKEND: redis://redis:6379/13
+ PRETIX_CELERY_BROKER: redis://redis:6379/12
+ PRETIX_DATABASE_BACKEND: postgresql
+ PRETIX_DATABASE_HOST: pretix-db
+ PRETIX_DATABASE_NAME: pretix
+ PRETIX_DATABASE_PASSWORD: pretix
+ PRETIX_DATABASE_PORT: 5432
+ PRETIX_DATABASE_USER: pretix
+ PRETIX_DJANGO_SECRET: local-pretix-secret-key
+ PRETIX_LOCALE_DEFAULT: en
+ PRETIX_LOCALE_TIMEZONE: Europe/Rome
+ PRETIX_PRETIX_CURRENCY: EUR
+ PRETIX_PRETIX_INSTANCE_NAME: PyCon Italia local
+ PRETIX_PRETIX_URL: http://localhost:8345
+ PRETIX_PYCON_MEDIA_BUCKET_NAME: ""
+ PRETIX_REDIS_LOCATION: redis://redis:6379/11
+ PRETIX_REDIS_SESSIONS: "false"
+ PYCON_LOCAL_PRETIX_TOKEN: ${LOCAL_PRETIX_API_TOKEN:-local-dashboard-api-token}
+ healthcheck:
+ test:
+ [
+ "CMD-SHELL",
+ "curl --fail --silent --header 'Host: localhost:8345' http://127.0.0.1/healthcheck/ > /dev/null",
+ ]
+ interval: 10s
+ timeout: 5s
+ retries: 30
+ start_period: 120s
+
+ pretix-db:
+ profiles: [pretix]
+ image: postgres:14.5
+ networks: [pycon_net]
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:15502:5432"
+ volumes:
+ - pycon-pretix-db-data:/var/lib/postgresql/data
+ environment:
+ POSTGRES_DB: pretix
+ POSTGRES_PASSWORD: pretix
+ POSTGRES_USER: pretix
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U pretix"]
+ interval: 5s
+ timeout: 5s
+
frontend:
build:
context: ./frontend
@@ -161,3 +228,5 @@ networks:
volumes:
pycon-backend-db-data:
+ pycon-pretix-data:
+ pycon-pretix-db-data:
diff --git a/pretix/dev/dashboard_seed.py b/pretix/dev/dashboard_seed.py
new file mode 100644
index 0000000000..f04e2c18a7
--- /dev/null
+++ b/pretix/dev/dashboard_seed.py
@@ -0,0 +1,111 @@
+"""Bootstrap and maintain the dashboard-only data in local Pretix.
+
+This file is executed through ``pretix shell`` by scripts/seed-dashboard-stage.
+It intentionally knows nothing about production credentials or event identifiers.
+"""
+
+import os
+
+from django.utils.dateparse import parse_datetime
+from django_scopes import scopes_disabled
+from pretix.base.models import Event, Organizer, Order, Team, TeamAPIToken, User
+
+ACTION = os.environ.get("DASHBOARD_SEED_ACTION", "reset")
+EVENT_SLUG = "dashboard-local"
+ORGANIZER_SLUG = "python-italia-local"
+TOKEN = os.environ.get("PYCON_LOCAL_PRETIX_TOKEN", "local-dashboard-api-token")
+
+
+def bootstrap_access() -> Organizer:
+ organizer, _ = Organizer.objects.get_or_create(
+ slug=ORGANIZER_SLUG,
+ defaults={"name": "Python Italia local"},
+ )
+ organizer.enable_plugin("pretix.plugins.banktransfer")
+ organizer.save()
+
+ user, _ = User.objects.get_or_create(
+ email="admin@localhost",
+ defaults={"fullname": "Local Pretix admin"},
+ )
+ user.fullname = "Local Pretix admin"
+ user.is_active = True
+ user.is_staff = True
+ user.is_verified = True
+ user.needs_password_change = False
+ user.set_password("admin")
+ user.save()
+
+ permissions = {
+ field.name: True
+ for field in Team._meta.fields
+ if field.name.startswith("can_")
+ }
+ team, _ = Team.objects.update_or_create(
+ organizer=organizer,
+ name="Local dashboard development",
+ defaults={"all_events": True, **permissions},
+ )
+ team.members.add(user)
+ TeamAPIToken.objects.update_or_create(
+ team=team,
+ name="Local dashboard seeder",
+ defaults={"active": True, "token": TOKEN},
+ )
+ return organizer
+
+
+def reset_event(organizer: Organizer) -> None:
+ with scopes_disabled():
+ event = Event.objects.filter(
+ organizer=organizer,
+ slug=EVENT_SLUG,
+ ).first()
+ if event is None:
+ print("Local Pretix access is ready; there was no seeded event to reset.")
+ return
+
+ orders = list(Order.objects.filter(event=event))
+ for order in orders:
+ Order.objects.filter(pk=order.pk).update(testmode=True)
+ order.testmode = True
+ order.gracefully_delete()
+
+ event.delete_sub_objects()
+ event.delete()
+ print(f"Reset local Pretix event and {len(orders)} orders.")
+
+
+def apply_order_dates(organizer: Organizer) -> None:
+ updated = 0
+ with scopes_disabled():
+ event = Event.objects.get(organizer=organizer, slug=EVENT_SLUG)
+ orders = Order.objects.filter(event=event)
+ for order in orders:
+ ordered_at = parse_datetime(
+ order.api_meta.get("dashboard_seed_ordered_at", "")
+ )
+ if ordered_at is None:
+ continue
+ Order.objects.filter(pk=order.pk).update(datetime=ordered_at)
+ updated += 1
+
+ event.settings.payment_banktransfer__enabled = True
+ event.settings.payment_banktransfer_bank_details = "Local development only"
+ event.clean_live()
+ event.live = True
+ event.save(update_fields=["live"])
+
+ print(
+ f"Applied historical timestamps to {updated} local Pretix orders and "
+ "published the local event."
+ )
+
+
+organizer = bootstrap_access()
+if ACTION == "reset":
+ reset_event(organizer)
+elif ACTION == "apply-order-dates":
+ apply_order_dates(organizer)
+else:
+ raise ValueError(f"Unknown DASHBOARD_SEED_ACTION: {ACTION}")
diff --git a/pretix/settings.py b/pretix/settings.py
index 98eb6e5084..272bfc2fcc 100644
--- a/pretix/settings.py
+++ b/pretix/settings.py
@@ -13,16 +13,12 @@
INSTALLED_APPS.remove("pretix_fattura_elettronica")
INSTALLED_APPS.insert(0, "pretix_fattura_elettronica")
-STORAGES = {
- "default": {
- "BACKEND": "storages.backends.s3.S3Storage",
- },
- "staticfiles": {
- "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
- },
-}
-
AWS_STORAGE_BUCKET_NAME = config.get("pycon", "media_bucket_name", fallback="")
AWS_S3_SIGNATURE_VERSION = "s3v4"
AWS_S3_ADDRESSING_STYLE = "virtual"
AWS_S3_REGION_NAME = "eu-central-1"
+
+if AWS_STORAGE_BUCKET_NAME:
+ STORAGES["default"] = {
+ "BACKEND": "storages.backends.s3.S3Storage",
+ }
diff --git a/scripts/seed-dashboard-stage b/scripts/seed-dashboard-stage
new file mode 100755
index 0000000000..56a807f4ac
--- /dev/null
+++ b/scripts/seed-dashboard-stage
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd -- "${script_dir}/.." && pwd)"
+stage="${1:-}"
+
+stages=(cfp-open review ticket-sales conference-week post-event)
+
+if [[ ! " ${stages[*]} " =~ " ${stage} " ]]; then
+ echo "Usage: ./scripts/seed-dashboard-stage "
+ echo "Stages: ${stages[*]}"
+ exit 2
+fi
+
+cd "${repo_root}"
+
+echo "Starting the local Pretix profile..."
+docker compose --profile pretix up -d --wait pretix
+docker compose up -d --wait backend
+
+echo "Resetting the dashboard-only Pretix event..."
+docker compose exec -T \
+ -e DASHBOARD_SEED_ACTION=reset \
+ pretix pretix shell < "${repo_root}/pretix/dev/dashboard_seed.py"
+
+echo "Seeding the ${stage} conference and Pretix stage..."
+docker compose exec -T \
+ -e DJANGO_SETTINGS_MODULE=pycon.settings.dev \
+ -e DASHBOARD_STAGE="${stage}" \
+ backend uv run python manage.py shell \
+ < "${repo_root}/backend/dashboard/dev/seed_stage.py"
+
+echo "Applying the historical Pretix order dates..."
+docker compose exec -T \
+ -e DASHBOARD_SEED_ACTION=apply-order-dates \
+ pretix pretix shell < "${repo_root}/pretix/dev/dashboard_seed.py"
+
+docker compose exec -T \
+ -e DJANGO_SETTINGS_MODULE=pycon.settings.dev \
+ backend uv run python manage.py shell \
+ -c "from django.core.cache import cache; cache.clear()"
+
+echo
+echo "Dashboard: http://localhost:8000/dashboard/dashboard-local"
+echo "Pretix: http://localhost:8345/control/ (admin@localhost / admin)"