From e691edb50a147a29b2df722fc790337040e4f658 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Fri, 14 Aug 2026 21:50:33 +0200 Subject: [PATCH] Add local dashboard staging Shortcake-Parent: 2026-08-14-add-dashboard-analytics --- README.md | 6 +- backend/dashboard/README.md | 91 +++++ backend/dashboard/dev/seed_stage.py | 564 ++++++++++++++++++++++++++++ docker-compose.yml | 71 +++- pretix/dev/dashboard_seed.py | 111 ++++++ pretix/settings.py | 14 +- scripts/seed-dashboard-stage | 47 +++ 7 files changed, 892 insertions(+), 12 deletions(-) create mode 100644 backend/dashboard/README.md create mode 100644 backend/dashboard/dev/seed_stage.py create mode 100644 pretix/dev/dashboard_seed.py create mode 100755 scripts/seed-dashboard-stage 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)"