From 5d9c45908d649e5e19c821aeb039ba5981392b97 Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Sun, 20 Sep 2026 12:39:13 +0200 Subject: [PATCH 1/7] Clone conference durable workflow Setting up the next edition means re-creating, by hand, everything that is configuration rather than content. `clone_conference` does it in one Resonate workflow: the conference itself (settings and taxonomies, never the pretix ids or the logo), then deadlines shifted by the gap between the two editions' start dates, durations, sponsor benefits/levels/special options, email templates, generic copy, FAQs, menus, forms and their questions, and voting included events. Each copy is a durable step wrapped in a transaction and keyed on the natural key of what it creates, so a failed run resumes from the step that failed and re-running never duplicates anything. i18n fields cannot be used in ORM lookups, so models keyed on one are de-duplicated in Python. Content produced during an edition -- proposals, schedule, keynotes, sponsors, grants, vouchers, answers -- is never copied. Triggered with `manage.py clone_conference ...`, whose workflow id defaults to `clone-conference-`. --- README.md | 18 + backend/conferences/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/clone_conference.py | 72 +++ .../tests/test_clone_conference_workflow.py | 322 ++++++++++++ backend/conferences/workflows/__init__.py | 1 + .../conferences/workflows/clone_conference.py | 480 ++++++++++++++++++ 7 files changed, 893 insertions(+) create mode 100644 backend/conferences/management/__init__.py create mode 100644 backend/conferences/management/commands/__init__.py create mode 100644 backend/conferences/management/commands/clone_conference.py create mode 100644 backend/conferences/tests/test_clone_conference_workflow.py create mode 100644 backend/conferences/workflows/__init__.py create mode 100644 backend/conferences/workflows/clone_conference.py diff --git a/README.md b/README.md index 17fee96b47..1617cb5f73 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,24 @@ To start a workflow from Django (a view, an admin action, a command), use `pycon.resonate_app.start_workflow(name, workflow_id, ...)`. The workflow id is the idempotency key: creating the same id twice joins the existing run. +Creating the next edition of the conference from the current one: + +``` +docker exec pycon-backend-1 uv run python manage.py clone_conference \ + pycon2026 pycon2027 \ + --name "PyCon Italia 2027" \ + --hostname pycon2027.pycon.it \ + --start 2027-05-26T09:00:00+02:00 \ + --end 2027-05-30T18:00:00+02:00 +``` + +It copies configuration only (deadlines, shifted by the new start date; +durations; sponsor levels, benefits and special options; email templates; +menus, copy and FAQs; forms and their questions; voting included events). +Everything produced during an edition -- proposals, schedule, keynotes, +sponsors, grants, vouchers, answers -- is not copied, and neither are the +pretix ids or the logo. + ## External repos Repos used by this project are in separate repositories. diff --git a/backend/conferences/management/__init__.py b/backend/conferences/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/conferences/management/commands/__init__.py b/backend/conferences/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/conferences/management/commands/clone_conference.py b/backend/conferences/management/commands/clone_conference.py new file mode 100644 index 0000000000..a46187de04 --- /dev/null +++ b/backend/conferences/management/commands/clone_conference.py @@ -0,0 +1,72 @@ +"""Trigger the clone-conference durable workflow.""" + +from django.core.management.base import BaseCommand, CommandError +from django.utils.dateparse import parse_datetime + +from conferences.models import Conference +from conferences.workflows import CLONE_CONFERENCE +from pycon.resonate_app import start_workflow + + +class Command(BaseCommand): + help = "Create a new conference using an existing one as base" + + def add_arguments(self, parser): + parser.add_argument("source_code", help="Code of the conference to copy") + parser.add_argument("new_code", help="Code of the conference to create") + parser.add_argument("--name", required=True, help="Name of the new conference") + parser.add_argument( + "--hostname", required=True, help="Hostname of the new conference" + ) + parser.add_argument( + "--start", required=True, help="Start date (ISO 8601, with timezone)" + ) + parser.add_argument( + "--end", required=True, help="End date (ISO 8601, with timezone)" + ) + parser.add_argument( + "--workflow-id", + default=None, + help=( + "Workflow id, the idempotency key of the run. " + "Defaults to clone-conference-." + ), + ) + + def handle(self, *args, **options): + source_code = options["source_code"] + + if not Conference.objects.filter(code=source_code).exists(): + raise CommandError(f"Conference {source_code} does not exist") + + start = self._parse_datetime(options["start"], "--start") + end = self._parse_datetime(options["end"], "--end") + + if start > end: + raise CommandError("--start cannot be after --end") + + new_code = options["new_code"] + workflow_id = options["workflow_id"] or f"clone-conference-{new_code}" + + start_workflow( + CLONE_CONFERENCE, + workflow_id, + source_code=source_code, + new_code=new_code, + new_name=options["name"], + new_start=start.isoformat(), + new_end=end.isoformat(), + new_hostname=options["hostname"], + ) + + self.stdout.write( + self.style.SUCCESS(f"Started workflow {workflow_id}, follow it in Resonate") + ) + + def _parse_datetime(self, value, flag): + parsed = parse_datetime(value) + + if parsed is None: + raise CommandError(f"{flag} is not a valid ISO 8601 datetime: {value}") + + return parsed diff --git a/backend/conferences/tests/test_clone_conference_workflow.py b/backend/conferences/tests/test_clone_conference_workflow.py new file mode 100644 index 0000000000..ce147df586 --- /dev/null +++ b/backend/conferences/tests/test_clone_conference_workflow.py @@ -0,0 +1,322 @@ +from datetime import datetime, timedelta + +import pytest +from asgiref.sync import async_to_sync +from django.utils import timezone +from resonate.resonate import Resonate +from resonate.retry import Never + +from cms.models import FAQ, GenericCopy, Menu +from cms.tests.factories import ( + FAQFactory, + GenericCopyFactory, + MenuFactory, + MenuLinkFactory, +) +from conferences.models import Conference, Deadline, Duration +from conferences.tests.factories import ( + AudienceLevelFactory, + ConferenceFactory, + DeadlineFactory, + DurationFactory, + TopicFactory, +) +from conferences.workflows import CLONE_CONFERENCE, clone_conference +from generic_forms.models import Form, FormQuestion +from generic_forms.tests.factories import FormFactory, FormQuestionFactory +from pycon.constants import UTC +from notifications.models import EmailTemplate, EmailTemplateIdentifier +from notifications.tests.factories import EmailTemplateFactory +from sponsors.models import ( + SponsorBenefit, + SponsorLevel, + SponsorLevelBenefit, + SponsorSpecialOption, +) +from sponsors.tests.factories import ( + SponsorBenefitFactory, + SponsorFactory, + SponsorLevelBenefitFactory, + SponsorLevelFactory, + SponsorSpecialOptionFactory, +) +from submissions.tests.factories import SubmissionTypeFactory +from voting.models import IncludedEvent +from voting.tests.factories.included_event import IncludedEventFactory + +pytestmark = pytest.mark.django_db + +NEW_START = datetime(2027, 5, 26, 9, 0, tzinfo=UTC) +NEW_END = datetime(2027, 5, 30, 18, 0, tzinfo=UTC) + + +def run_clone(source_code, new_code="pycon2027", workflow_id=None, **kwargs): + """Run the workflow in-process, on Resonate's local (in-memory) connection. + + ``async_to_sync`` (rather than ``asyncio.run``) so the steps' thread + sensitive database work runs back in this thread -- the one holding the + test transaction. + """ + + async def go(): + resonate = Resonate(autostart=False, retry_policy=Never()) + resonate.register(clone_conference, name=CLONE_CONFERENCE) + resonate.start() + + try: + handle = resonate.run( + workflow_id or f"clone-{new_code}", + clone_conference, + source_code=source_code, + new_code=new_code, + new_name=kwargs.get("new_name", "PyCon Italia 2027"), + new_start=kwargs.get("new_start", NEW_START).isoformat(), + new_end=kwargs.get("new_end", NEW_END).isoformat(), + new_hostname=kwargs.get("new_hostname", "pycon2027.example.com"), + ) + return await handle.result() + finally: + await resonate.stop() + + return async_to_sync(go)() + + +@pytest.fixture +def source_conference(): + return ConferenceFactory( + code="pycon2026", + start=datetime(2026, 5, 27, 9, 0, tzinfo=UTC), + end=datetime(2026, 5, 31, 18, 0, tzinfo=UTC), + hostname="pycon2026.example.com", + location="Bologna", + slack_new_proposal_channel_id="C123", + max_proposals_per_user=3, + ) + + +def test_creates_the_new_conference_from_the_source(source_conference): + source_conference.topics.add(TopicFactory(name="Web")) + source_conference.audience_levels.add(AudienceLevelFactory(name="Beginner")) + source_conference.submission_types.add(SubmissionTypeFactory(name="Talk")) + + result = run_clone(source_conference.code) + + conference = Conference.objects.get(code="pycon2027") + + assert result["conference_id"] == conference.id + assert str(conference.name) == "PyCon Italia 2027" + assert conference.hostname == "pycon2027.example.com" + assert conference.start == NEW_START + assert conference.end == NEW_END + assert conference.organizer_id == source_conference.organizer_id + assert conference.location == "Bologna" + assert conference.slack_new_proposal_channel_id == "C123" + assert conference.max_proposals_per_user == 3 + assert list(conference.topics.all()) == list(source_conference.topics.all()) + assert list(conference.audience_levels.all()) == list( + source_conference.audience_levels.all() + ) + assert list(conference.submission_types.all()) == list( + source_conference.submission_types.all() + ) + + +def test_does_not_copy_edition_specific_data(source_conference): + run_clone(source_conference.code) + + conference = Conference.objects.get(code="pycon2027") + + assert conference.pretix_organizer_id == "" + assert conference.pretix_event_id == "" + assert conference.pretix_event_url == "" + assert conference.pretix_conference_voucher_quota_id is None + + +def test_copies_deadlines_shifted_by_the_new_start_date(source_conference): + DeadlineFactory( + conference=source_conference, + type=Deadline.TYPES.cfp, + start=datetime(2026, 1, 10, 10, 0, tzinfo=UTC), + end=datetime(2026, 2, 10, 10, 0, tzinfo=UTC), + ) + + result = run_clone(source_conference.code) + + shift = NEW_START - source_conference.start + deadline = Deadline.objects.get( + conference__code="pycon2027", type=Deadline.TYPES.cfp + ) + + assert result["copied"]["deadlines"] == 1 + assert deadline.start == datetime(2026, 1, 10, 10, 0, tzinfo=UTC) + shift + assert deadline.end == datetime(2026, 2, 10, 10, 0, tzinfo=UTC) + shift + + +def test_copies_durations_with_their_submission_types(source_conference): + submission_type = SubmissionTypeFactory(name="Talk") + duration = DurationFactory( + conference=source_conference, name="Standard", duration=45, notes="Notes" + ) + duration.allowed_submission_types.add(submission_type) + + result = run_clone(source_conference.code) + + new_duration = Duration.objects.get(conference__code="pycon2027", name="Standard") + + assert result["copied"]["durations"] == 1 + assert new_duration.duration == 45 + assert new_duration.notes == "Notes" + assert list(new_duration.allowed_submission_types.all()) == [submission_type] + + +def test_copies_sponsor_configuration_without_the_sponsors(source_conference): + benefit = SponsorBenefitFactory( + conference=source_conference, name="Booth", category="booth" + ) + level = SponsorLevelFactory( + conference=source_conference, name="Gold", price=1000, slots=3 + ) + level.sponsors.add(SponsorFactory()) + SponsorLevelBenefitFactory(sponsor_level=level, benefit=benefit, value="2") + SponsorSpecialOptionFactory( + conference=source_conference, name="Job board", price=100 + ) + + run_clone(source_conference.code) + + conference = Conference.objects.get(code="pycon2027") + new_level = SponsorLevel.objects.get(conference=conference, name="Gold") + new_benefit = SponsorBenefit.objects.get(conference=conference) + + assert new_level.price == 1000 + assert new_level.slots == 3 + assert new_level.sponsors.count() == 0 + assert str(new_benefit.name) == "Booth" + assert SponsorLevelBenefit.objects.filter( + sponsor_level=new_level, benefit=new_benefit + ).exists() + assert SponsorSpecialOption.objects.filter( + conference=conference, name="Job board" + ).exists() + + +def test_copies_email_templates(source_conference): + EmailTemplateFactory( + conference=source_conference, + identifier=EmailTemplateIdentifier.proposal_accepted, + subject="Congrats", + body="Body", + ) + EmailTemplateFactory( + conference=source_conference, + identifier=EmailTemplateIdentifier.custom, + name="Sponsors update", + subject="Update", + ) + + result = run_clone(source_conference.code) + + conference = Conference.objects.get(code="pycon2027") + accepted = EmailTemplate.objects.get( + conference=conference, identifier=EmailTemplateIdentifier.proposal_accepted + ) + + assert result["copied"]["email_templates"] == 2 + assert accepted.subject == "Congrats" + assert EmailTemplate.objects.filter( + conference=conference, + identifier=EmailTemplateIdentifier.custom, + name="Sponsors update", + ).exists() + + +def test_copies_cms_content(source_conference): + GenericCopyFactory(conference=source_conference, key="homepage-intro") + FAQFactory(conference=source_conference) + menu = MenuFactory(conference=source_conference, identifier="main") + MenuLinkFactory(menu=menu) + + run_clone(source_conference.code) + + conference = Conference.objects.get(code="pycon2027") + new_menu = Menu.objects.get(conference=conference, identifier="main") + + assert GenericCopy.objects.filter( + conference=conference, key="homepage-intro" + ).exists() + assert FAQ.objects.filter(conference=conference).count() == 1 + assert new_menu.links.count() == 1 + + +def test_copies_forms_and_questions_but_no_answers(source_conference): + form = FormFactory( + conference=source_conference, purpose=Form.Purpose.GRANT, name="Grant form" + ) + FormQuestionFactory(form=form, label="Why do you need a grant?", required=True) + + run_clone(source_conference.code) + + conference = Conference.objects.get(code="pycon2027") + new_form = Form.objects.get(conference=conference, purpose=Form.Purpose.GRANT) + question = FormQuestion.objects.get(form=new_form) + + assert new_form.name == "Grant form" + assert question.label == "Why do you need a grant?" + assert question.required is True + assert new_form.answers.count() == 0 + + +def test_copies_voting_included_events(source_conference): + IncludedEventFactory( + conference=source_conference, + pretix_organizer_id="python-italia", + pretix_event_id="pycon2025", + ) + + run_clone(source_conference.code) + + assert IncludedEvent.objects.filter( + conference__code="pycon2027", + pretix_organizer_id="python-italia", + pretix_event_id="pycon2025", + ).exists() + + +def test_running_the_workflow_again_does_not_duplicate_anything(source_conference): + DeadlineFactory(conference=source_conference, type=Deadline.TYPES.cfp) + DurationFactory(conference=source_conference, name="Standard") + SponsorLevelFactory(conference=source_conference, name="Gold") + GenericCopyFactory(conference=source_conference, key="homepage-intro") + + first = run_clone(source_conference.code, workflow_id="clone-first-run") + second = run_clone(source_conference.code, workflow_id="clone-second-run") + + assert second["conference_id"] == first["conference_id"] + assert Conference.objects.filter(code="pycon2027").count() == 1 + assert Deadline.objects.filter(conference__code="pycon2027").count() == 1 + assert Duration.objects.filter(conference__code="pycon2027").count() == 1 + assert SponsorLevel.objects.filter(conference__code="pycon2027").count() == 1 + assert GenericCopy.objects.filter(conference__code="pycon2027").count() == 1 + assert all(count == 0 for count in second["copied"].values()) + + +def test_deadlines_are_not_shifted_when_the_source_has_no_dates(source_conference): + source_conference.start = None + source_conference.end = None + source_conference.save() + + start = timezone.now() + DeadlineFactory( + conference=source_conference, + type=Deadline.TYPES.cfp, + start=start, + end=start + timedelta(days=30), + ) + + run_clone(source_conference.code) + + deadline = Deadline.objects.get( + conference__code="pycon2027", type=Deadline.TYPES.cfp + ) + + assert deadline.start == start diff --git a/backend/conferences/workflows/__init__.py b/backend/conferences/workflows/__init__.py new file mode 100644 index 0000000000..813bf42459 --- /dev/null +++ b/backend/conferences/workflows/__init__.py @@ -0,0 +1 @@ +from .clone_conference import CLONE_CONFERENCE, clone_conference # noqa diff --git a/backend/conferences/workflows/clone_conference.py b/backend/conferences/workflows/clone_conference.py new file mode 100644 index 0000000000..9f51bca8c5 --- /dev/null +++ b/backend/conferences/workflows/clone_conference.py @@ -0,0 +1,480 @@ +"""Durable workflow that creates a new conference from an existing one. + +Setting up the next edition means re-creating, by hand, everything that is +configuration rather than content: deadlines, durations, sponsor levels and +their benefits, email templates, menus, copy, forms. The workflow does that in +one durable run: every step is a Resonate durable child, so a crash (or a +database hiccup, which Resonate retries) resumes from the step that failed +instead of leaving a half-built conference behind. + +Content produced *during* an edition is never copied: proposals, schedule, +keynotes, sponsors, grants, vouchers, answers. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta +from typing import Any + +from django.db import transaction +from resonate.context import Context + +from pycon.resonate_app import database_step, get_resonate + +resonate = get_resonate() + +CLONE_CONFERENCE = "clone_conference" + +# Copied verbatim onto the new conference: settings that describe how the +# organizers work, not what happened at a given edition. Anything tied to one +# edition (pretix ids, logo, dates, code, hostname) is deliberately absent. +CONFERENCE_FIELDS_TO_COPY = ( + "organizer_id", + "timezone", + "location", + "introduction", + "latitude", + "longitude", + "map_link", + "slack_new_proposal_channel_id", + "slack_new_grant_reply_channel_id", + "slack_speaker_invitation_answer_channel_id", + "slack_new_sponsor_lead_channel_id", + "slack_new_invitation_letter_request_channel_id", + "video_title_template", + "video_description_template", + "youtube_video_bottom_text", + "frontend_revalidate_url", + "frontend_revalidate_secret", + "max_proposals_per_user", +) + +CONFERENCE_TAXONOMIES_TO_COPY = ( + "topics", + "languages", + "audience_levels", + "submission_types", + "proposal_tags", +) + + +def _i18n_key(value: Any) -> str: + """A comparable key for an i18n field value. + + i18n fields cannot be used in ORM lookups (they raise), so models keyed on + one are de-duplicated in Python instead. + """ + data = getattr(value, "data", value) + + if isinstance(data, dict): + return json.dumps(data, sort_keys=True) + + return str(data) + + +@database_step +def create_conference( + ctx: Context, + source_code: str, + new_code: str, + new_name: str, + new_start: datetime, + new_end: datetime, + new_hostname: str, +) -> int: + """Create the new conference (or return the existing one) and its taxonomies.""" + from conferences.models import Conference + + with transaction.atomic(): + source = Conference.objects.get(code=source_code) + + conference, _ = Conference.objects.get_or_create( + code=new_code, + defaults={ + "name": new_name, + "hostname": new_hostname, + "start": new_start, + "end": new_end, + **{ + field: getattr(source, field) for field in CONFERENCE_FIELDS_TO_COPY + }, + }, + ) + + for taxonomy in CONFERENCE_TAXONOMIES_TO_COPY: + getattr(conference, taxonomy).set(getattr(source, taxonomy).all()) + + return conference.id + + +@database_step +def copy_deadlines(ctx: Context, source_code: str, conference_id: int) -> int: + """Copy deadlines, shifted by the gap between the two editions' start dates.""" + from conferences.models import Conference, Deadline + + with transaction.atomic(): + source = Conference.objects.get(code=source_code) + conference = Conference.objects.get(id=conference_id) + + if source.start and conference.start: + shift = conference.start - source.start + else: + shift = timedelta() + + copied = 0 + + for deadline in source.deadlines.all(): + _, created = Deadline.objects.get_or_create( + conference=conference, + type=deadline.type, + defaults={ + "name": deadline.name, + "description": deadline.description, + "start": deadline.start + shift, + "end": deadline.end + shift, + }, + ) + copied += int(created) + + return copied + + +@database_step +def copy_durations(ctx: Context, source_code: str, conference_id: int) -> int: + from conferences.models import Conference, Duration + + with transaction.atomic(): + source = Conference.objects.get(code=source_code) + conference = Conference.objects.get(id=conference_id) + + copied = 0 + + for duration in source.durations.all(): + new_duration, created = Duration.objects.get_or_create( + conference=conference, + name=duration.name, + defaults={ + "duration": duration.duration, + "notes": duration.notes, + }, + ) + new_duration.allowed_submission_types.set( + duration.allowed_submission_types.all() + ) + copied += int(created) + + return copied + + +@database_step +def copy_sponsor_benefits(ctx: Context, source_code: str, conference_id: int) -> int: + from conferences.models import Conference + from sponsors.models import SponsorBenefit + + with transaction.atomic(): + source = Conference.objects.get(code=source_code) + conference = Conference.objects.get(id=conference_id) + + existing = { + _i18n_key(benefit.name) + for benefit in SponsorBenefit.objects.filter(conference=conference) + } + copied = 0 + + for benefit in source.sponsor_benefits.order_by("order"): + if _i18n_key(benefit.name) in existing: + continue + + SponsorBenefit.objects.create( + conference=conference, + name=benefit.name, + category=benefit.category, + description=benefit.description, + ) + copied += 1 + + return copied + + +@database_step +def copy_sponsor_levels(ctx: Context, source_code: str, conference_id: int) -> int: + """Copy sponsor levels and which benefits they include (never the sponsors).""" + from conferences.models import Conference + from sponsors.models import SponsorBenefit, SponsorLevel, SponsorLevelBenefit + + with transaction.atomic(): + source = Conference.objects.get(code=source_code) + conference = Conference.objects.get(id=conference_id) + + benefits_by_name = { + _i18n_key(benefit.name): benefit + for benefit in SponsorBenefit.objects.filter(conference=conference) + } + copied = 0 + + for level in source.sponsor_levels.order_by("order"): + new_level, created = SponsorLevel.objects.get_or_create( + conference=conference, + name=level.name, + defaults={ + "highlight_color": level.highlight_color, + "price": level.price, + "slots": level.slots, + }, + ) + copied += int(created) + + for level_benefit in SponsorLevelBenefit.objects.filter( + sponsor_level=level + ): + benefit = benefits_by_name.get(_i18n_key(level_benefit.benefit.name)) + + if benefit is None: + continue + + SponsorLevelBenefit.objects.get_or_create( + sponsor_level=new_level, + benefit=benefit, + defaults={"value": level_benefit.value}, + ) + + return copied + + +@database_step +def copy_sponsor_special_options( + ctx: Context, source_code: str, conference_id: int +) -> int: + from conferences.models import Conference + from sponsors.models import SponsorSpecialOption + + with transaction.atomic(): + source = Conference.objects.get(code=source_code) + conference = Conference.objects.get(id=conference_id) + + copied = 0 + + for option in source.sponsor_special_options.order_by("order"): + _, created = SponsorSpecialOption.objects.get_or_create( + conference=conference, + name=option.name, + defaults={ + "description": option.description, + "price": option.price, + }, + ) + copied += int(created) + + return copied + + +@database_step +def copy_email_templates(ctx: Context, source_code: str, conference_id: int) -> int: + from conferences.models import Conference + from notifications.models import EmailTemplate, EmailTemplateIdentifier + + with transaction.atomic(): + source = Conference.objects.get(code=source_code) + conference = Conference.objects.get(id=conference_id) + + copied = 0 + + for template in source.email_templates.all(): + lookup = {"conference": conference, "identifier": template.identifier} + + # Only custom templates can repeat an identifier, so they are told + # apart by name. + if template.identifier == EmailTemplateIdentifier.custom: + lookup["name"] = template.name + + _, created = EmailTemplate.objects.get_or_create( + **lookup, + defaults={ + "name": template.name, + "reply_to": template.reply_to, + "subject": template.subject, + "preview_text": template.preview_text, + "body": template.body, + "cc_addresses": template.cc_addresses, + "bcc_addresses": template.bcc_addresses, + }, + ) + copied += int(created) + + return copied + + +@database_step +def copy_cms_content(ctx: Context, source_code: str, conference_id: int) -> int: + """Copy generic copy, FAQs and menus (with their links).""" + from cms.models import FAQ, GenericCopy, Menu, MenuLink + from conferences.models import Conference + + with transaction.atomic(): + source = Conference.objects.get(code=source_code) + conference = Conference.objects.get(id=conference_id) + + copied = 0 + + for copy in source.copy.all(): + _, created = GenericCopy.objects.get_or_create( + conference=conference, + key=copy.key, + defaults={"content": copy.content}, + ) + copied += int(created) + + existing_faqs = { + _i18n_key(faq.question) for faq in FAQ.objects.filter(conference=conference) + } + + for faq in source.faqs.all(): + if _i18n_key(faq.question) in existing_faqs: + continue + + FAQ.objects.create( + conference=conference, question=faq.question, answer=faq.answer + ) + copied += 1 + + for menu in source.menus.all(): + new_menu, created = Menu.objects.get_or_create( + conference=conference, + identifier=menu.identifier, + defaults={"title": menu.title}, + ) + copied += int(created) + + existing_links = {_i18n_key(link.title) for link in new_menu.links.all()} + + for link in menu.links.order_by("order"): + if _i18n_key(link.title) in existing_links: + continue + + MenuLink.objects.create( + menu=new_menu, + title=link.title, + href=link.href, + is_primary=link.is_primary, + ) + copied += 1 + + return copied + + +@database_step +def copy_forms(ctx: Context, source_code: str, conference_id: int) -> int: + """Copy forms and their questions; answers belong to the old edition.""" + from conferences.models import Conference + from generic_forms.models import Form, FormQuestion + + with transaction.atomic(): + source = Conference.objects.get(code=source_code) + conference = Conference.objects.get(id=conference_id) + + copied = 0 + + for form in source.forms.all(): + new_form, created = Form.objects.get_or_create( + conference=conference, + purpose=form.purpose, + name=form.name, + ) + copied += int(created) + + for question in form.questions.all(): + _, question_created = FormQuestion.objects.get_or_create( + form=new_form, + label=question.label, + defaults={ + "description": question.description, + "question_type": question.question_type, + "options": question.options, + "required": question.required, + "max_length": question.max_length, + "order": question.order, + "active": question.active, + }, + ) + copied += int(question_created) + + return copied + + +@database_step +def copy_voting_included_events( + ctx: Context, source_code: str, conference_id: int +) -> int: + from conferences.models import Conference + from voting.models import IncludedEvent + + with transaction.atomic(): + source = Conference.objects.get(code=source_code) + conference = Conference.objects.get(id=conference_id) + + copied = 0 + + for included_event in source.included_voting_events.all(): + _, created = IncludedEvent.objects.get_or_create( + conference=conference, + pretix_organizer_id=included_event.pretix_organizer_id, + pretix_event_id=included_event.pretix_event_id, + ) + copied += int(created) + + return copied + + +# Everything that only needs the source and the freshly created conference, +# in the order it is copied. Sponsor levels read the benefits copied just +# before them, so the order matters. +COPY_STEPS = ( + ("deadlines", copy_deadlines), + ("durations", copy_durations), + ("sponsor_benefits", copy_sponsor_benefits), + ("sponsor_levels", copy_sponsor_levels), + ("sponsor_special_options", copy_sponsor_special_options), + ("email_templates", copy_email_templates), + ("cms_content", copy_cms_content), + ("forms", copy_forms), + ("voting_included_events", copy_voting_included_events), +) + + +@resonate.register(name=CLONE_CONFERENCE) +async def clone_conference( + ctx: Context, + source_code: str, + new_code: str, + new_name: str, + new_start: datetime, + new_end: datetime, + new_hostname: str, +) -> dict[str, Any]: + """Create ``new_code`` using ``source_code`` as its base. + + Re-running with the same workflow id joins the original run; re-running + with a new id against a conference that already exists tops up whatever is + missing, since every step only creates what is not there yet. + """ + conference_id: int = await ctx.run( + create_conference, + source_code, + new_code, + new_name, + new_start, + new_end, + new_hostname, + ) + + copied: dict[str, int] = {} + + for name, step in COPY_STEPS: + copied[name] = await ctx.run(step, source_code, conference_id) + + return { + "conference_id": conference_id, + "code": new_code, + "copied": copied, + } From 105864de3b82f7ce33942a77823d1c51568a2cbc Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Sun, 20 Sep 2026 16:52:35 +0200 Subject: [PATCH 2/7] Start the clone from an admin action instead of a command Creating the next edition is something organizers do from the admin, not from a shell: selecting the conference to use as base and running *Create new conference using this one as base* opens a form for the code, name, hostname and dates of the new edition, prefilled with the same values one year later, and starts the workflow. The form refuses a code or hostname already in use, and a start after the end. Each submission gets its own workflow id so a run that failed for good can be started again; a double submit is harmless anyway, since every step of the workflow only creates what is not there yet. --- README.md | 15 +- backend/conferences/admin/actions.py | 134 +++++++++++++++ backend/conferences/admin/conference.py | 7 +- backend/conferences/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/clone_conference.py | 72 -------- .../admin/conferences/clone_conference.html | 55 ++++++ backend/conferences/tests/test_admin.py | 160 +++++++++++++++++- 8 files changed, 358 insertions(+), 85 deletions(-) delete mode 100644 backend/conferences/management/__init__.py delete mode 100644 backend/conferences/management/commands/__init__.py delete mode 100644 backend/conferences/management/commands/clone_conference.py create mode 100644 backend/conferences/templates/admin/conferences/clone_conference.html diff --git a/README.md b/README.md index 1617cb5f73..6d90d03259 100644 --- a/README.md +++ b/README.md @@ -71,16 +71,11 @@ To start a workflow from Django (a view, an admin action, a command), use `pycon.resonate_app.start_workflow(name, workflow_id, ...)`. The workflow id is the idempotency key: creating the same id twice joins the existing run. -Creating the next edition of the conference from the current one: - -``` -docker exec pycon-backend-1 uv run python manage.py clone_conference \ - pycon2026 pycon2027 \ - --name "PyCon Italia 2027" \ - --hostname pycon2027.pycon.it \ - --start 2027-05-26T09:00:00+02:00 \ - --end 2027-05-30T18:00:00+02:00 -``` +Creating the next edition of the conference is one of those workflows: select +the conference to use as base in the admin and run the *Create new conference +using this one as base* action, which asks for the code, name, hostname and +dates of the new edition (prefilled with the next year's) and starts the +workflow. It copies configuration only (deadlines, shifted by the new start date; durations; sponsor levels, benefits and special options; email templates; diff --git a/backend/conferences/admin/actions.py b/backend/conferences/admin/actions.py index 3c8644cdb5..62b98d0bf9 100644 --- a/backend/conferences/admin/actions.py +++ b/backend/conferences/admin/actions.py @@ -1,7 +1,18 @@ +from datetime import timedelta +from uuid import uuid4 + +from django import forms from django.contrib import admin, messages +from django.contrib.admin import widgets +from django.core.exceptions import ValidationError +from django.template.response import TemplateResponse + +from conferences.models import Conference from conferences.tasks import send_conference_voucher_email +from conferences.workflows import CLONE_CONFERENCE from custom_admin.admin import validate_single_conference_selection from pretix import create_voucher +from pycon.resonate_app import start_workflow @admin.action(description="Send voucher via email") @@ -48,3 +59,126 @@ def create_conference_vouchers_on_pretix(modeladmin, request, queryset): count = count + 1 messages.success(request, f"{count} Vouchers created on Pretix!") + + +class CloneConferenceForm(forms.Form): + """What the new conference overrides; everything else comes from the base one.""" + + code = forms.CharField(max_length=100) + name = forms.CharField(max_length=100) + hostname = forms.CharField(max_length=255) + start = forms.SplitDateTimeField(widget=widgets.AdminSplitDateTime()) + end = forms.SplitDateTimeField(widget=widgets.AdminSplitDateTime()) + + def clean_code(self): + code = self.cleaned_data["code"] + + if Conference.objects.filter(code=code).exists(): + raise ValidationError(f"Conference {code} already exists") + + return code + + def clean_hostname(self): + hostname = self.cleaned_data["hostname"] + + if Conference.objects.filter(hostname=hostname).exists(): + raise ValidationError(f"Hostname {hostname} is already used") + + return hostname + + def clean(self): + cleaned_data = super().clean() + start = cleaned_data.get("start") + end = cleaned_data.get("end") + + if start and end and start > end: + raise ValidationError("Start date cannot be after end") + + return cleaned_data + + +def next_edition_defaults(conference: Conference) -> dict: + """Prefill the form with the next edition: same values, one year later.""" + year = conference.start.year if conference.start else None + + def bump_year(value: str) -> str: + if not year or str(year) not in value: + return value + + return value.replace(str(year), str(year + 1), 1) + + def next_year(value): + if not value: + return value + + try: + return value.replace(year=value.year + 1) + except ValueError: + # 29th of February: the next edition has no such day. + return value + timedelta(days=365) + + return { + "code": bump_year(conference.code), + "name": bump_year(str(conference.name)), + "hostname": bump_year(conference.hostname), + "start": next_year(conference.start), + "end": next_year(conference.end), + } + + +@admin.action(description="Create new conference using this one as base") +def clone_conference(modeladmin, request, queryset): + if queryset.count() != 1: + messages.error(request, "Select the single conference to use as base") + return None + + source = queryset.get() + + if request.POST.get("apply"): + form = CloneConferenceForm(request.POST) + + if form.is_valid(): + return start_clone_conference(request, source, form.cleaned_data) + else: + form = CloneConferenceForm(initial=next_edition_defaults(source)) + + return TemplateResponse( + request, + "admin/conferences/clone_conference.html", + { + **modeladmin.admin_site.each_context(request), + "title": f"New conference based on {source.code}", + "opts": modeladmin.model._meta, + "media": modeladmin.media + form.media, + "source": source, + "form": form, + "action_name": "clone_conference", + "selected": [str(source.pk)], + }, + ) + + +def start_clone_conference(request, source: Conference, data: dict) -> None: + # A fresh id per submission, so a run that failed for good can be started + # again. Double submits are harmless anyway: every step of the workflow + # only creates what is not there yet. + workflow_id = f"clone-conference-{data['code']}-{uuid4().hex[:8]}" + + start_workflow( + CLONE_CONFERENCE, + workflow_id, + source_code=source.code, + new_code=data["code"], + new_name=data["name"], + new_start=data["start"].isoformat(), + new_end=data["end"].isoformat(), + new_hostname=data["hostname"], + ) + + messages.success( + request, + f"Creating {data['code']} based on {source.code}. " + f"Follow the progress in Resonate (workflow {workflow_id}).", + ) + + return None diff --git a/backend/conferences/admin/conference.py b/backend/conferences/admin/conference.py index 4761ee4f6e..eebd8c48ee 100644 --- a/backend/conferences/admin/conference.py +++ b/backend/conferences/admin/conference.py @@ -35,7 +35,11 @@ Topic, ) from conferences.admin.views import grants_summary -from .actions import send_voucher_via_email, create_conference_vouchers_on_pretix +from .actions import ( + clone_conference, + create_conference_vouchers_on_pretix, + send_voucher_via_email, +) def validate_deadlines_form(forms): @@ -192,6 +196,7 @@ class ConferenceAdmin( ("YouTube", {"fields": ("video_title_template", "video_description_template")}), ) inlines = [DeadlineInline, DurationInline, SponsorLevelInline, IncludedEventInline] + actions = [clone_conference] @admin.display(description="Schedule Builder") def schedule_builder_link(self, obj): diff --git a/backend/conferences/management/__init__.py b/backend/conferences/management/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/conferences/management/commands/__init__.py b/backend/conferences/management/commands/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/conferences/management/commands/clone_conference.py b/backend/conferences/management/commands/clone_conference.py deleted file mode 100644 index a46187de04..0000000000 --- a/backend/conferences/management/commands/clone_conference.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Trigger the clone-conference durable workflow.""" - -from django.core.management.base import BaseCommand, CommandError -from django.utils.dateparse import parse_datetime - -from conferences.models import Conference -from conferences.workflows import CLONE_CONFERENCE -from pycon.resonate_app import start_workflow - - -class Command(BaseCommand): - help = "Create a new conference using an existing one as base" - - def add_arguments(self, parser): - parser.add_argument("source_code", help="Code of the conference to copy") - parser.add_argument("new_code", help="Code of the conference to create") - parser.add_argument("--name", required=True, help="Name of the new conference") - parser.add_argument( - "--hostname", required=True, help="Hostname of the new conference" - ) - parser.add_argument( - "--start", required=True, help="Start date (ISO 8601, with timezone)" - ) - parser.add_argument( - "--end", required=True, help="End date (ISO 8601, with timezone)" - ) - parser.add_argument( - "--workflow-id", - default=None, - help=( - "Workflow id, the idempotency key of the run. " - "Defaults to clone-conference-." - ), - ) - - def handle(self, *args, **options): - source_code = options["source_code"] - - if not Conference.objects.filter(code=source_code).exists(): - raise CommandError(f"Conference {source_code} does not exist") - - start = self._parse_datetime(options["start"], "--start") - end = self._parse_datetime(options["end"], "--end") - - if start > end: - raise CommandError("--start cannot be after --end") - - new_code = options["new_code"] - workflow_id = options["workflow_id"] or f"clone-conference-{new_code}" - - start_workflow( - CLONE_CONFERENCE, - workflow_id, - source_code=source_code, - new_code=new_code, - new_name=options["name"], - new_start=start.isoformat(), - new_end=end.isoformat(), - new_hostname=options["hostname"], - ) - - self.stdout.write( - self.style.SUCCESS(f"Started workflow {workflow_id}, follow it in Resonate") - ) - - def _parse_datetime(self, value, flag): - parsed = parse_datetime(value) - - if parsed is None: - raise CommandError(f"{flag} is not a valid ISO 8601 datetime: {value}") - - return parsed diff --git a/backend/conferences/templates/admin/conferences/clone_conference.html b/backend/conferences/templates/admin/conferences/clone_conference.html new file mode 100644 index 0000000000..2560bd2056 --- /dev/null +++ b/backend/conferences/templates/admin/conferences/clone_conference.html @@ -0,0 +1,55 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls %} + +{% block extrahead %}{{ block.super }}{{ media }}{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

+ The new conference starts as a copy of {{ source }}: deadlines + (shifted by the new start date), durations, sponsor levels, benefits and special + options, email templates, menus, copy and FAQs, forms and their questions, and + voting included events. Proposals, schedule, keynotes, sponsors, grants, + vouchers and answers are not copied, and neither are the pretix ids or the logo. +

+ +
+ {% csrf_token %} + + {% for pk in selected %} + + {% endfor %} + + + +
+ {% for field in form %} +
+ {{ field.errors }} +
+ {{ field.label_tag }} + {{ field }} +
+
+ {% endfor %} + {% if form.non_field_errors %} +

{{ form.non_field_errors }}

+ {% endif %} +
+ + +
+{% endblock %} diff --git a/backend/conferences/tests/test_admin.py b/backend/conferences/tests/test_admin.py index 887dbb9c34..89b1cf4d0d 100644 --- a/backend/conferences/tests/test_admin.py +++ b/backend/conferences/tests/test_admin.py @@ -1,4 +1,4 @@ -from datetime import date, time +from datetime import date, datetime, time from schedule.tests.factories import ( DayFactory, @@ -25,6 +25,7 @@ ConferenceAdmin, ) from conferences.admin.actions import ( + clone_conference, create_conference_vouchers_on_pretix, send_voucher_via_email, ) @@ -33,7 +34,9 @@ walk_conference_videos_folder, DeadlineForm, ) -from conferences.models import ConferenceVoucher +from conferences.models import Conference, ConferenceVoucher +from conferences.workflows import CLONE_CONFERENCE +from pycon.constants import UTC from schedule.models import ScheduleItem pytestmark = mark.django_db @@ -941,3 +944,156 @@ def test_save_manual_changes( assert event_1.video_uploaded_path == "test" assert event_2.video_uploaded_path == "another-2" assert event_3.video_uploaded_path == "another-3" + + +def clone_conference_request(rf, admin_superuser, data=None): + request = rf.post("/", data or {}) + request.user = admin_superuser + return request + + +def test_clone_conference_action_shows_the_form_with_the_next_edition( + rf, admin_superuser +): + conference = ConferenceFactory( + code="pycon2026", + name="PyCon Italia 2026", + hostname="pycon2026.pycon.it", + start=datetime(2026, 5, 27, 9, 0, tzinfo=UTC), + end=datetime(2026, 5, 31, 18, 0, tzinfo=UTC), + ) + + response = clone_conference( + ConferenceAdmin(Conference, AdminSite()), + clone_conference_request(rf, admin_superuser), + queryset=Conference.objects.filter(id=conference.id), + ) + + initial = response.context_data["form"].initial + + assert initial["code"] == "pycon2027" + assert initial["name"] == "PyCon Italia 2027" + assert initial["hostname"] == "pycon2027.pycon.it" + assert initial["start"] == datetime(2027, 5, 27, 9, 0, tzinfo=UTC) + assert initial["end"] == datetime(2027, 5, 31, 18, 0, tzinfo=UTC) + + +def test_clone_conference_action_requires_a_single_conference( + rf, mocker, admin_superuser +): + mock_messages = mocker.patch("conferences.admin.actions.messages") + ConferenceFactory() + ConferenceFactory() + + response = clone_conference( + ConferenceAdmin(Conference, AdminSite()), + clone_conference_request(rf, admin_superuser), + queryset=Conference.objects.all(), + ) + + assert response is None + mock_messages.error.assert_called_once() + + +def test_clone_conference_action_starts_the_workflow(rf, mocker, admin_superuser): + mocker.patch("conferences.admin.actions.messages") + mock_start_workflow = mocker.patch("conferences.admin.actions.start_workflow") + + conference = ConferenceFactory(code="pycon2026") + + response = clone_conference( + ConferenceAdmin(Conference, AdminSite()), + clone_conference_request( + rf, + admin_superuser, + { + "apply": "1", + "code": "pycon2027", + "name": "PyCon Italia 2027", + "hostname": "pycon2027.pycon.it", + "start_0": "2027-05-26", + "start_1": "09:00:00", + "end_0": "2027-05-30", + "end_1": "18:00:00", + }, + ), + queryset=Conference.objects.filter(id=conference.id), + ) + + assert response is None + + kwargs = mock_start_workflow.call_args.kwargs + + assert mock_start_workflow.call_args.args[0] == CLONE_CONFERENCE + assert mock_start_workflow.call_args.args[1].startswith( + "clone-conference-pycon2027" + ) + assert kwargs["source_code"] == "pycon2026" + assert kwargs["new_code"] == "pycon2027" + assert kwargs["new_name"] == "PyCon Italia 2027" + assert kwargs["new_hostname"] == "pycon2027.pycon.it" + assert kwargs["new_start"].startswith("2027-05-26T09:00:00") + assert kwargs["new_end"].startswith("2027-05-30T18:00:00") + + +def test_clone_conference_action_refuses_an_existing_code(rf, mocker, admin_superuser): + mock_start_workflow = mocker.patch("conferences.admin.actions.start_workflow") + + conference = ConferenceFactory(code="pycon2026") + ConferenceFactory(code="pycon2027", hostname="taken.pycon.it") + + response = clone_conference( + ConferenceAdmin(Conference, AdminSite()), + clone_conference_request( + rf, + admin_superuser, + { + "apply": "1", + "code": "pycon2027", + "name": "PyCon Italia 2027", + "hostname": "pycon2027.pycon.it", + "start_0": "2027-05-26", + "start_1": "09:00:00", + "end_0": "2027-05-30", + "end_1": "18:00:00", + }, + ), + queryset=Conference.objects.filter(id=conference.id), + ) + + assert response.context_data["form"].errors["code"] == [ + "Conference pycon2027 already exists" + ] + mock_start_workflow.assert_not_called() + + +def test_clone_conference_action_refuses_a_start_after_the_end( + rf, mocker, admin_superuser +): + mock_start_workflow = mocker.patch("conferences.admin.actions.start_workflow") + + conference = ConferenceFactory(code="pycon2026") + + response = clone_conference( + ConferenceAdmin(Conference, AdminSite()), + clone_conference_request( + rf, + admin_superuser, + { + "apply": "1", + "code": "pycon2027", + "name": "PyCon Italia 2027", + "hostname": "pycon2027.pycon.it", + "start_0": "2027-05-30", + "start_1": "18:00:00", + "end_0": "2027-05-26", + "end_1": "09:00:00", + }, + ), + queryset=Conference.objects.filter(id=conference.id), + ) + + assert response.context_data["form"].non_field_errors() == [ + "Start date cannot be after end" + ] + mock_start_workflow.assert_not_called() From 40b3fc90e037d73e481791dbf39265d3d5be7fa3 Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Sun, 20 Sep 2026 17:54:19 +0200 Subject: [PATCH 3/7] Run the workflow tests on the in-process connection The bare `Resonate()` the tests build picks up RESONATE_URL from the environment, so in a container that has one they ran against the real server and replayed promises left by an earlier run instead of executing the workflow. `env={}` pins them to the in-process connection. --- backend/conferences/tests/test_clone_conference_workflow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/conferences/tests/test_clone_conference_workflow.py b/backend/conferences/tests/test_clone_conference_workflow.py index ce147df586..fd666bed24 100644 --- a/backend/conferences/tests/test_clone_conference_workflow.py +++ b/backend/conferences/tests/test_clone_conference_workflow.py @@ -59,7 +59,9 @@ def run_clone(source_code, new_code="pycon2027", workflow_id=None, **kwargs): """ async def go(): - resonate = Resonate(autostart=False, retry_policy=Never()) + # ``env={}`` hides any ambient RESONATE_URL, so the workflow runs on + # the in-process connection instead of a real server. + resonate = Resonate(autostart=False, retry_policy=Never(), env={}) resonate.register(clone_conference, name=CLONE_CONFERENCE) resonate.start() From 0078f52e2a35b26f47708c1bf21bddf1441fe658 Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Sun, 20 Sep 2026 18:10:32 +0200 Subject: [PATCH 4/7] Rebuild the schedule days when cloning a conference The new edition gets one day per date it runs on, in its own timezone, and each takes the rooms and slots of the source day in the same position, so a conference that opens with a workshop day keeps that shape. Days the source edition did not have are left empty, and the streaming and Sli.do links stay with the edition that used them. --- README.md | 11 +-- .../admin/conferences/clone_conference.html | 10 ++- .../tests/test_clone_conference_workflow.py | 81 ++++++++++++++++++- .../conferences/workflows/clone_conference.py | 76 ++++++++++++++--- 4 files changed, 159 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6d90d03259..6d2fb698fb 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,12 @@ dates of the new edition (prefilled with the next year's) and starts the workflow. It copies configuration only (deadlines, shifted by the new start date; -durations; sponsor levels, benefits and special options; email templates; -menus, copy and FAQs; forms and their questions; voting included events). -Everything produced during an edition -- proposals, schedule, keynotes, -sponsors, grants, vouchers, answers -- is not copied, and neither are the -pretix ids or the logo. +durations; the schedule days, laid out over the new dates with the rooms and +slots of the edition it copies; sponsor levels, benefits and special options; +email templates; menus, copy and FAQs; forms and their questions; voting +included events). Everything produced during an edition -- proposals, the talks +scheduled into those slots, keynotes, sponsors, grants, vouchers, answers -- is +not copied, and neither are the pretix ids or the logo. ## External repos diff --git a/backend/conferences/templates/admin/conferences/clone_conference.html b/backend/conferences/templates/admin/conferences/clone_conference.html index 2560bd2056..a40a79487f 100644 --- a/backend/conferences/templates/admin/conferences/clone_conference.html +++ b/backend/conferences/templates/admin/conferences/clone_conference.html @@ -15,10 +15,12 @@ {% block content %}

The new conference starts as a copy of {{ source }}: deadlines - (shifted by the new start date), durations, sponsor levels, benefits and special - options, email templates, menus, copy and FAQs, forms and their questions, and - voting included events. Proposals, schedule, keynotes, sponsors, grants, - vouchers and answers are not copied, and neither are the pretix ids or the logo. + (shifted by the new start date), durations, the schedule days (laid out over the + dates below, with the rooms and slots of the edition they copy), sponsor levels, + benefits and special options, email templates, menus, copy and FAQs, forms and + their questions, and voting included events. Proposals, the talks scheduled into + those slots, keynotes, sponsors, grants, vouchers and answers are not copied, + and neither are the pretix ids or the logo.

diff --git a/backend/conferences/tests/test_clone_conference_workflow.py b/backend/conferences/tests/test_clone_conference_workflow.py index fd666bed24..3f27f1785d 100644 --- a/backend/conferences/tests/test_clone_conference_workflow.py +++ b/backend/conferences/tests/test_clone_conference_workflow.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import date, datetime, time, timedelta import pytest from asgiref.sync import async_to_sync @@ -27,6 +27,8 @@ from pycon.constants import UTC from notifications.models import EmailTemplate, EmailTemplateIdentifier from notifications.tests.factories import EmailTemplateFactory +from schedule.models import DayRoomThroughModel, Slot +from schedule.tests.factories import DayFactory, RoomFactory, SlotFactory from sponsors.models import ( SponsorBenefit, SponsorLevel, @@ -322,3 +324,80 @@ def test_deadlines_are_not_shifted_when_the_source_has_no_dates(source_conferenc ) assert deadline.start == start + + +def test_copies_the_days_over_the_new_conference_dates(source_conference): + room = RoomFactory(name="Main room") + other_room = RoomFactory(name="Second room") + + first_day = DayFactory(conference=source_conference, day=date(2026, 5, 27)) + DayRoomThroughModel.objects.create( + day=first_day, room=room, streaming_url="https://youtube.example/live" + ) + DayRoomThroughModel.objects.create(day=first_day, room=other_room) + SlotFactory(day=first_day, hour=time(9, 0), duration=30, type="break") + SlotFactory(day=first_day, hour=time(9, 30), duration=45) + + second_day = DayFactory(conference=source_conference, day=date(2026, 5, 28)) + DayRoomThroughModel.objects.create(day=second_day, room=room) + + result = run_clone(source_conference.code) + + conference = Conference.objects.get(code="pycon2027") + days = list(conference.days.order_by("day")) + + # The new edition runs from the 26th to the 30th, one day per date. + assert result["copied"]["days"] == 5 + assert [day.day for day in days] == [ + date(2027, 5, 26), + date(2027, 5, 27), + date(2027, 5, 28), + date(2027, 5, 29), + date(2027, 5, 30), + ] + + assert list(days[0].ordered_rooms()) == [room, other_room] + assert [(slot.hour, slot.duration, slot.type) for slot in days[0].slots.all()] == [ + (time(9, 0), 30, "break"), + (time(9, 30), 45, Slot.TYPES.default), + ] + + assert list(days[1].ordered_rooms()) == [room] + + # The source edition only had two days, the rest stay empty. + assert days[2].added_rooms.count() == 0 + assert days[2].slots.count() == 0 + + +def test_does_not_copy_the_streaming_links_of_the_days(source_conference): + day = DayFactory(conference=source_conference, day=date(2026, 5, 27)) + DayRoomThroughModel.objects.create( + day=day, + room=RoomFactory(), + streaming_url="https://youtube.example/live", + slido_url="https://slido.example/event", + ) + + run_clone(source_conference.code) + + new_day = Conference.objects.get(code="pycon2027").days.get(day=date(2027, 5, 26)) + added_room = new_day.added_rooms.get() + + assert added_room.streaming_url == "" + assert added_room.slido_url == "" + + +def test_days_use_the_conference_timezone(source_conference): + # 23:30 UTC is already the next day in Rome, where the conference is. + run_clone( + source_conference.code, + new_start=datetime(2027, 5, 25, 23, 30, tzinfo=UTC), + new_end=datetime(2027, 5, 26, 23, 30, tzinfo=UTC), + ) + + conference = Conference.objects.get(code="pycon2027") + + assert [day.day for day in conference.days.order_by("day")] == [ + date(2027, 5, 26), + date(2027, 5, 27), + ] diff --git a/backend/conferences/workflows/clone_conference.py b/backend/conferences/workflows/clone_conference.py index 9f51bca8c5..52cc19726c 100644 --- a/backend/conferences/workflows/clone_conference.py +++ b/backend/conferences/workflows/clone_conference.py @@ -1,20 +1,21 @@ """Durable workflow that creates a new conference from an existing one. Setting up the next edition means re-creating, by hand, everything that is -configuration rather than content: deadlines, durations, sponsor levels and -their benefits, email templates, menus, copy, forms. The workflow does that in -one durable run: every step is a Resonate durable child, so a crash (or a -database hiccup, which Resonate retries) resumes from the step that failed -instead of leaving a half-built conference behind. - -Content produced *during* an edition is never copied: proposals, schedule, -keynotes, sponsors, grants, vouchers, answers. +configuration rather than content: deadlines, durations, the schedule days +with their rooms and slots, sponsor levels and their benefits, email templates, +menus, copy, forms. The workflow does that in one durable run: every step is a +Resonate durable child, so a crash (or a database hiccup, which Resonate +retries) resumes from the step that failed instead of leaving a half-built +conference behind. + +Content produced *during* an edition is never copied: proposals, the talks +scheduled into those slots, keynotes, sponsors, grants, vouchers, answers. """ from __future__ import annotations import json -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from typing import Any from django.db import transaction @@ -140,6 +141,17 @@ def copy_deadlines(ctx: Context, source_code: str, conference_id: int) -> int: return copied +def conference_dates(conference) -> list[date]: + """Every day the conference runs on, in its own timezone.""" + if not conference.start or not conference.end: + return [] + + start = conference.start.astimezone(conference.timezone).date() + end = conference.end.astimezone(conference.timezone).date() + + return [start + timedelta(days=offset) for offset in range((end - start).days + 1)] + + @database_step def copy_durations(ctx: Context, source_code: str, conference_id: int) -> int: from conferences.models import Conference, Duration @@ -167,6 +179,51 @@ def copy_durations(ctx: Context, source_code: str, conference_id: int) -> int: return copied +@database_step +def copy_days(ctx: Context, source_code: str, conference_id: int) -> int: + """Lay the schedule out again over the new conference's own dates. + + The new conference gets one day per date it runs on, and each takes the + rooms and slots of the source day in the same position, so a conference + that opens with a workshop day keeps that shape. Days the source edition + did not have are left empty. Streaming and Sli.do links stay with the + edition that used them. + """ + from conferences.models import Conference + from schedule.models import Day, DayRoomThroughModel, Slot + + with transaction.atomic(): + source = Conference.objects.get(code=source_code) + conference = Conference.objects.get(id=conference_id) + + source_days = list(source.days.order_by("day")) + copied = 0 + + for position, day_date in enumerate(conference_dates(conference)): + day, created = Day.objects.get_or_create( + conference=conference, day=day_date + ) + copied += int(created) + + if position >= len(source_days): + continue + + source_day = source_days[position] + + for added_room in source_day.added_rooms.order_by("order"): + DayRoomThroughModel.objects.get_or_create(day=day, room=added_room.room) + + for slot in source_day.slots.order_by("hour"): + Slot.objects.get_or_create( + day=day, + hour=slot.hour, + duration=slot.duration, + type=slot.type, + ) + + return copied + + @database_step def copy_sponsor_benefits(ctx: Context, source_code: str, conference_id: int) -> int: from conferences.models import Conference @@ -432,6 +489,7 @@ def copy_voting_included_events( COPY_STEPS = ( ("deadlines", copy_deadlines), ("durations", copy_durations), + ("days", copy_days), ("sponsor_benefits", copy_sponsor_benefits), ("sponsor_levels", copy_sponsor_levels), ("sponsor_special_options", copy_sponsor_special_options), From 116da5b652c2c9e7c7ab38f3dbf0a165b98bff65 Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Sun, 20 Sep 2026 18:18:37 +0200 Subject: [PATCH 5/7] Lift the boilerplate out of the clone steps Every copy step opened with the same four lines: import the models, open a transaction, and look up the two conferences. A `copy_step` decorator now does that once and hands each step the source and the new conference, so what is left in each is only what it copies. `copy_cms_content` covered three unrelated kinds of content in one long body; it now calls one function per kind. --- .../tests/test_clone_conference_workflow.py | 18 +- .../conferences/workflows/clone_conference.py | 572 +++++++++--------- 2 files changed, 287 insertions(+), 303 deletions(-) diff --git a/backend/conferences/tests/test_clone_conference_workflow.py b/backend/conferences/tests/test_clone_conference_workflow.py index 3f27f1785d..c89789af7e 100644 --- a/backend/conferences/tests/test_clone_conference_workflow.py +++ b/backend/conferences/tests/test_clone_conference_workflow.py @@ -52,7 +52,15 @@ NEW_END = datetime(2027, 5, 30, 18, 0, tzinfo=UTC) -def run_clone(source_code, new_code="pycon2027", workflow_id=None, **kwargs): +def run_clone( + source_code, + new_code="pycon2027", + workflow_id=None, + new_name="PyCon Italia 2027", + new_start=NEW_START, + new_end=NEW_END, + new_hostname="pycon2027.example.com", +): """Run the workflow in-process, on Resonate's local (in-memory) connection. ``async_to_sync`` (rather than ``asyncio.run``) so the steps' thread @@ -73,10 +81,10 @@ async def go(): clone_conference, source_code=source_code, new_code=new_code, - new_name=kwargs.get("new_name", "PyCon Italia 2027"), - new_start=kwargs.get("new_start", NEW_START).isoformat(), - new_end=kwargs.get("new_end", NEW_END).isoformat(), - new_hostname=kwargs.get("new_hostname", "pycon2027.example.com"), + new_name=new_name, + new_start=new_start.isoformat(), + new_end=new_end.isoformat(), + new_hostname=new_hostname, ) return await handle.result() finally: diff --git a/backend/conferences/workflows/clone_conference.py b/backend/conferences/workflows/clone_conference.py index 52cc19726c..62d97215c0 100644 --- a/backend/conferences/workflows/clone_conference.py +++ b/backend/conferences/workflows/clone_conference.py @@ -16,12 +16,24 @@ import json from datetime import date, datetime, timedelta -from typing import Any +from typing import Any, Callable from django.db import transaction from resonate.context import Context +from cms.models import FAQ, GenericCopy, Menu, MenuLink +from conferences.models import Conference, Deadline, Duration +from generic_forms.models import Form, FormQuestion +from notifications.models import EmailTemplate, EmailTemplateIdentifier from pycon.resonate_app import database_step, get_resonate +from schedule.models import Day, DayRoomThroughModel, Slot +from sponsors.models import ( + SponsorBenefit, + SponsorLevel, + SponsorLevelBenefit, + SponsorSpecialOption, +) +from voting.models import IncludedEvent resonate = get_resonate() @@ -60,7 +72,7 @@ ) -def _i18n_key(value: Any) -> str: +def i18n_key(value: Any) -> str: """A comparable key for an i18n field value. i18n fields cannot be used in ORM lookups (they raise), so models keyed on @@ -74,6 +86,40 @@ def _i18n_key(value: Any) -> str: return str(data) +def conference_dates(conference: Conference) -> list[date]: + """Every day the conference runs on, in its own timezone.""" + if not conference.start or not conference.end: + return [] + + start = conference.start.astimezone(conference.timezone).date() + end = conference.end.astimezone(conference.timezone).date() + + return [start + timedelta(days=offset) for offset in range((end - start).days + 1)] + + +def copy_step(fn: Callable[[Conference, Conference], int]): + """Make a durable step out of a function copying one conference's config. + + Every step needs the same two conferences, one transaction around its + writes, and returns how many objects it created, so that lives here rather + than in each of them. The step keeps the ``(ctx, source_code, + conference_id)`` signature the SDK inspects -- which is why it cannot use + ``functools.wraps``, as that would point the signature back at ``fn``. + """ + + def step(ctx: Context, source_code: str, conference_id: int) -> int: + with transaction.atomic(): + source = Conference.objects.get(code=source_code) + conference = Conference.objects.get(id=conference_id) + + return fn(source, conference) + + step.__name__ = fn.__name__ + step.__doc__ = fn.__doc__ + + return database_step(step) + + @database_step def create_conference( ctx: Context, @@ -85,8 +131,6 @@ def create_conference( new_hostname: str, ) -> int: """Create the new conference (or return the existing one) and its taxonomies.""" - from conferences.models import Conference - with transaction.atomic(): source = Conference.objects.get(code=source_code) @@ -109,78 +153,55 @@ def create_conference( return conference.id -@database_step -def copy_deadlines(ctx: Context, source_code: str, conference_id: int) -> int: +@copy_step +def copy_deadlines(source: Conference, conference: Conference) -> int: """Copy deadlines, shifted by the gap between the two editions' start dates.""" - from conferences.models import Conference, Deadline - - with transaction.atomic(): - source = Conference.objects.get(code=source_code) - conference = Conference.objects.get(id=conference_id) - - if source.start and conference.start: - shift = conference.start - source.start - else: - shift = timedelta() + if source.start and conference.start: + shift = conference.start - source.start + else: + shift = timedelta() - copied = 0 - - for deadline in source.deadlines.all(): - _, created = Deadline.objects.get_or_create( - conference=conference, - type=deadline.type, - defaults={ - "name": deadline.name, - "description": deadline.description, - "start": deadline.start + shift, - "end": deadline.end + shift, - }, - ) - copied += int(created) - - return copied - - -def conference_dates(conference) -> list[date]: - """Every day the conference runs on, in its own timezone.""" - if not conference.start or not conference.end: - return [] - - start = conference.start.astimezone(conference.timezone).date() - end = conference.end.astimezone(conference.timezone).date() - - return [start + timedelta(days=offset) for offset in range((end - start).days + 1)] + copied = 0 + for deadline in source.deadlines.all(): + _, created = Deadline.objects.get_or_create( + conference=conference, + type=deadline.type, + defaults={ + "name": deadline.name, + "description": deadline.description, + "start": deadline.start + shift, + "end": deadline.end + shift, + }, + ) + copied += int(created) -@database_step -def copy_durations(ctx: Context, source_code: str, conference_id: int) -> int: - from conferences.models import Conference, Duration + return copied - with transaction.atomic(): - source = Conference.objects.get(code=source_code) - conference = Conference.objects.get(id=conference_id) - copied = 0 +@copy_step +def copy_durations(source: Conference, conference: Conference) -> int: + copied = 0 - for duration in source.durations.all(): - new_duration, created = Duration.objects.get_or_create( - conference=conference, - name=duration.name, - defaults={ - "duration": duration.duration, - "notes": duration.notes, - }, - ) - new_duration.allowed_submission_types.set( - duration.allowed_submission_types.all() - ) - copied += int(created) + for duration in source.durations.all(): + new_duration, created = Duration.objects.get_or_create( + conference=conference, + name=duration.name, + defaults={ + "duration": duration.duration, + "notes": duration.notes, + }, + ) + new_duration.allowed_submission_types.set( + duration.allowed_submission_types.all() + ) + copied += int(created) - return copied + return copied -@database_step -def copy_days(ctx: Context, source_code: str, conference_id: int) -> int: +@copy_step +def copy_days(source: Conference, conference: Conference) -> int: """Lay the schedule out again over the new conference's own dates. The new conference gets one day per date it runs on, and each takes the @@ -189,298 +210,253 @@ def copy_days(ctx: Context, source_code: str, conference_id: int) -> int: did not have are left empty. Streaming and Sli.do links stay with the edition that used them. """ - from conferences.models import Conference - from schedule.models import Day, DayRoomThroughModel, Slot + source_days = list(source.days.order_by("day")) + copied = 0 - with transaction.atomic(): - source = Conference.objects.get(code=source_code) - conference = Conference.objects.get(id=conference_id) + for position, day_date in enumerate(conference_dates(conference)): + day, created = Day.objects.get_or_create(conference=conference, day=day_date) + copied += int(created) - source_days = list(source.days.order_by("day")) - copied = 0 + if position >= len(source_days): + continue - for position, day_date in enumerate(conference_dates(conference)): - day, created = Day.objects.get_or_create( - conference=conference, day=day_date + source_day = source_days[position] + + for added_room in source_day.added_rooms.order_by("order"): + DayRoomThroughModel.objects.get_or_create(day=day, room=added_room.room) + + for slot in source_day.slots.order_by("hour"): + Slot.objects.get_or_create( + day=day, + hour=slot.hour, + duration=slot.duration, + type=slot.type, ) - copied += int(created) - if position >= len(source_days): - continue + return copied - source_day = source_days[position] - for added_room in source_day.added_rooms.order_by("order"): - DayRoomThroughModel.objects.get_or_create(day=day, room=added_room.room) +@copy_step +def copy_sponsor_benefits(source: Conference, conference: Conference) -> int: + existing = { + i18n_key(benefit.name) + for benefit in SponsorBenefit.objects.filter(conference=conference) + } + copied = 0 - for slot in source_day.slots.order_by("hour"): - Slot.objects.get_or_create( - day=day, - hour=slot.hour, - duration=slot.duration, - type=slot.type, - ) + for benefit in source.sponsor_benefits.order_by("order"): + if i18n_key(benefit.name) in existing: + continue - return copied + SponsorBenefit.objects.create( + conference=conference, + name=benefit.name, + category=benefit.category, + description=benefit.description, + ) + copied += 1 + return copied -@database_step -def copy_sponsor_benefits(ctx: Context, source_code: str, conference_id: int) -> int: - from conferences.models import Conference - from sponsors.models import SponsorBenefit - with transaction.atomic(): - source = Conference.objects.get(code=source_code) - conference = Conference.objects.get(id=conference_id) +@copy_step +def copy_sponsor_levels(source: Conference, conference: Conference) -> int: + """Copy sponsor levels and which benefits they include (never the sponsors).""" + benefits_by_name = { + i18n_key(benefit.name): benefit + for benefit in SponsorBenefit.objects.filter(conference=conference) + } + copied = 0 - existing = { - _i18n_key(benefit.name) - for benefit in SponsorBenefit.objects.filter(conference=conference) - } - copied = 0 + for level in source.sponsor_levels.order_by("order"): + new_level, created = SponsorLevel.objects.get_or_create( + conference=conference, + name=level.name, + defaults={ + "highlight_color": level.highlight_color, + "price": level.price, + "slots": level.slots, + }, + ) + copied += int(created) - for benefit in source.sponsor_benefits.order_by("order"): - if _i18n_key(benefit.name) in existing: + for level_benefit in SponsorLevelBenefit.objects.filter(sponsor_level=level): + benefit = benefits_by_name.get(i18n_key(level_benefit.benefit.name)) + + if benefit is None: continue - SponsorBenefit.objects.create( - conference=conference, - name=benefit.name, - category=benefit.category, - description=benefit.description, + SponsorLevelBenefit.objects.get_or_create( + sponsor_level=new_level, + benefit=benefit, + defaults={"value": level_benefit.value}, ) - copied += 1 - return copied + return copied -@database_step -def copy_sponsor_levels(ctx: Context, source_code: str, conference_id: int) -> int: - """Copy sponsor levels and which benefits they include (never the sponsors).""" - from conferences.models import Conference - from sponsors.models import SponsorBenefit, SponsorLevel, SponsorLevelBenefit +@copy_step +def copy_sponsor_special_options(source: Conference, conference: Conference) -> int: + copied = 0 - with transaction.atomic(): - source = Conference.objects.get(code=source_code) - conference = Conference.objects.get(id=conference_id) - - benefits_by_name = { - _i18n_key(benefit.name): benefit - for benefit in SponsorBenefit.objects.filter(conference=conference) - } - copied = 0 - - for level in source.sponsor_levels.order_by("order"): - new_level, created = SponsorLevel.objects.get_or_create( - conference=conference, - name=level.name, - defaults={ - "highlight_color": level.highlight_color, - "price": level.price, - "slots": level.slots, - }, - ) - copied += int(created) - - for level_benefit in SponsorLevelBenefit.objects.filter( - sponsor_level=level - ): - benefit = benefits_by_name.get(_i18n_key(level_benefit.benefit.name)) + for option in source.sponsor_special_options.order_by("order"): + _, created = SponsorSpecialOption.objects.get_or_create( + conference=conference, + name=option.name, + defaults={ + "description": option.description, + "price": option.price, + }, + ) + copied += int(created) - if benefit is None: - continue + return copied - SponsorLevelBenefit.objects.get_or_create( - sponsor_level=new_level, - benefit=benefit, - defaults={"value": level_benefit.value}, - ) - return copied +@copy_step +def copy_email_templates(source: Conference, conference: Conference) -> int: + copied = 0 + for template in source.email_templates.all(): + lookup = {"conference": conference, "identifier": template.identifier} -@database_step -def copy_sponsor_special_options( - ctx: Context, source_code: str, conference_id: int -) -> int: - from conferences.models import Conference - from sponsors.models import SponsorSpecialOption + # Only custom templates can repeat an identifier, so they are told + # apart by name. + if template.identifier == EmailTemplateIdentifier.custom: + lookup["name"] = template.name - with transaction.atomic(): - source = Conference.objects.get(code=source_code) - conference = Conference.objects.get(id=conference_id) + _, created = EmailTemplate.objects.get_or_create( + **lookup, + defaults={ + "name": template.name, + "reply_to": template.reply_to, + "subject": template.subject, + "preview_text": template.preview_text, + "body": template.body, + "cc_addresses": template.cc_addresses, + "bcc_addresses": template.bcc_addresses, + }, + ) + copied += int(created) - copied = 0 + return copied - for option in source.sponsor_special_options.order_by("order"): - _, created = SponsorSpecialOption.objects.get_or_create( - conference=conference, - name=option.name, - defaults={ - "description": option.description, - "price": option.price, - }, - ) - copied += int(created) - return copied +@copy_step +def copy_cms_content(source: Conference, conference: Conference) -> int: + """Copy generic copy, FAQs and menus (with their links).""" + return ( + copy_generic_copy(source, conference) + + copy_faqs(source, conference) + + copy_menus(source, conference) + ) -@database_step -def copy_email_templates(ctx: Context, source_code: str, conference_id: int) -> int: - from conferences.models import Conference - from notifications.models import EmailTemplate, EmailTemplateIdentifier +def copy_generic_copy(source: Conference, conference: Conference) -> int: + copied = 0 - with transaction.atomic(): - source = Conference.objects.get(code=source_code) - conference = Conference.objects.get(id=conference_id) - - copied = 0 + for copy in source.copy.all(): + _, created = GenericCopy.objects.get_or_create( + conference=conference, + key=copy.key, + defaults={"content": copy.content}, + ) + copied += int(created) - for template in source.email_templates.all(): - lookup = {"conference": conference, "identifier": template.identifier} + return copied - # Only custom templates can repeat an identifier, so they are told - # apart by name. - if template.identifier == EmailTemplateIdentifier.custom: - lookup["name"] = template.name - _, created = EmailTemplate.objects.get_or_create( - **lookup, - defaults={ - "name": template.name, - "reply_to": template.reply_to, - "subject": template.subject, - "preview_text": template.preview_text, - "body": template.body, - "cc_addresses": template.cc_addresses, - "bcc_addresses": template.bcc_addresses, - }, - ) - copied += int(created) +def copy_faqs(source: Conference, conference: Conference) -> int: + existing = { + i18n_key(faq.question) for faq in FAQ.objects.filter(conference=conference) + } + copied = 0 - return copied + for faq in source.faqs.all(): + if i18n_key(faq.question) in existing: + continue + FAQ.objects.create( + conference=conference, question=faq.question, answer=faq.answer + ) + copied += 1 -@database_step -def copy_cms_content(ctx: Context, source_code: str, conference_id: int) -> int: - """Copy generic copy, FAQs and menus (with their links).""" - from cms.models import FAQ, GenericCopy, Menu, MenuLink - from conferences.models import Conference + return copied - with transaction.atomic(): - source = Conference.objects.get(code=source_code) - conference = Conference.objects.get(id=conference_id) - copied = 0 +def copy_menus(source: Conference, conference: Conference) -> int: + copied = 0 - for copy in source.copy.all(): - _, created = GenericCopy.objects.get_or_create( - conference=conference, - key=copy.key, - defaults={"content": copy.content}, - ) - copied += int(created) + for menu in source.menus.all(): + new_menu, created = Menu.objects.get_or_create( + conference=conference, + identifier=menu.identifier, + defaults={"title": menu.title}, + ) + copied += int(created) - existing_faqs = { - _i18n_key(faq.question) for faq in FAQ.objects.filter(conference=conference) - } + existing_links = {i18n_key(link.title) for link in new_menu.links.all()} - for faq in source.faqs.all(): - if _i18n_key(faq.question) in existing_faqs: + for link in menu.links.order_by("order"): + if i18n_key(link.title) in existing_links: continue - FAQ.objects.create( - conference=conference, question=faq.question, answer=faq.answer + MenuLink.objects.create( + menu=new_menu, + title=link.title, + href=link.href, + is_primary=link.is_primary, ) copied += 1 - for menu in source.menus.all(): - new_menu, created = Menu.objects.get_or_create( - conference=conference, - identifier=menu.identifier, - defaults={"title": menu.title}, - ) - copied += int(created) - - existing_links = {_i18n_key(link.title) for link in new_menu.links.all()} - - for link in menu.links.order_by("order"): - if _i18n_key(link.title) in existing_links: - continue + return copied - MenuLink.objects.create( - menu=new_menu, - title=link.title, - href=link.href, - is_primary=link.is_primary, - ) - copied += 1 - return copied - - -@database_step -def copy_forms(ctx: Context, source_code: str, conference_id: int) -> int: +@copy_step +def copy_forms(source: Conference, conference: Conference) -> int: """Copy forms and their questions; answers belong to the old edition.""" - from conferences.models import Conference - from generic_forms.models import Form, FormQuestion - - with transaction.atomic(): - source = Conference.objects.get(code=source_code) - conference = Conference.objects.get(id=conference_id) + copied = 0 - copied = 0 + for form in source.forms.all(): + new_form, created = Form.objects.get_or_create( + conference=conference, + purpose=form.purpose, + name=form.name, + ) + copied += int(created) - for form in source.forms.all(): - new_form, created = Form.objects.get_or_create( - conference=conference, - purpose=form.purpose, - name=form.name, + for question in form.questions.all(): + _, question_created = FormQuestion.objects.get_or_create( + form=new_form, + label=question.label, + defaults={ + "description": question.description, + "question_type": question.question_type, + "options": question.options, + "required": question.required, + "max_length": question.max_length, + "order": question.order, + "active": question.active, + }, ) - copied += int(created) - - for question in form.questions.all(): - _, question_created = FormQuestion.objects.get_or_create( - form=new_form, - label=question.label, - defaults={ - "description": question.description, - "question_type": question.question_type, - "options": question.options, - "required": question.required, - "max_length": question.max_length, - "order": question.order, - "active": question.active, - }, - ) - copied += int(question_created) - - return copied - + copied += int(question_created) -@database_step -def copy_voting_included_events( - ctx: Context, source_code: str, conference_id: int -) -> int: - from conferences.models import Conference - from voting.models import IncludedEvent + return copied - with transaction.atomic(): - source = Conference.objects.get(code=source_code) - conference = Conference.objects.get(id=conference_id) - copied = 0 +@copy_step +def copy_voting_included_events(source: Conference, conference: Conference) -> int: + copied = 0 - for included_event in source.included_voting_events.all(): - _, created = IncludedEvent.objects.get_or_create( - conference=conference, - pretix_organizer_id=included_event.pretix_organizer_id, - pretix_event_id=included_event.pretix_event_id, - ) - copied += int(created) + for included_event in source.included_voting_events.all(): + _, created = IncludedEvent.objects.get_or_create( + conference=conference, + pretix_organizer_id=included_event.pretix_organizer_id, + pretix_event_id=included_event.pretix_event_id, + ) + copied += int(created) - return copied + return copied # Everything that only needs the source and the freshly created conference, From d5504f912e956500029b9e477ea36a7e232e489a Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Sun, 20 Sep 2026 18:18:42 +0200 Subject: [PATCH 6/7] Drop the multi-selection shape from the clone action The action already refuses anything but a single conference, so passing the selection to the template as a list -- and the action's own name as a context variable -- only made the page look more general than it is. --- backend/conferences/admin/actions.py | 10 ++++------ .../templates/admin/conferences/clone_conference.html | 6 ++---- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/backend/conferences/admin/actions.py b/backend/conferences/admin/actions.py index 62b98d0bf9..2a3682e1a2 100644 --- a/backend/conferences/admin/actions.py +++ b/backend/conferences/admin/actions.py @@ -128,11 +128,13 @@ def next_year(value): @admin.action(description="Create new conference using this one as base") def clone_conference(modeladmin, request, queryset): - if queryset.count() != 1: + selected = list(queryset) + + if len(selected) != 1: messages.error(request, "Select the single conference to use as base") return None - source = queryset.get() + source = selected[0] if request.POST.get("apply"): form = CloneConferenceForm(request.POST) @@ -152,8 +154,6 @@ def clone_conference(modeladmin, request, queryset): "media": modeladmin.media + form.media, "source": source, "form": form, - "action_name": "clone_conference", - "selected": [str(source.pk)], }, ) @@ -180,5 +180,3 @@ def start_clone_conference(request, source: Conference, data: dict) -> None: f"Creating {data['code']} based on {source.code}. " f"Follow the progress in Resonate (workflow {workflow_id}).", ) - - return None diff --git a/backend/conferences/templates/admin/conferences/clone_conference.html b/backend/conferences/templates/admin/conferences/clone_conference.html index a40a79487f..4d5c6599a0 100644 --- a/backend/conferences/templates/admin/conferences/clone_conference.html +++ b/backend/conferences/templates/admin/conferences/clone_conference.html @@ -26,10 +26,8 @@ {% csrf_token %} - {% for pk in selected %} - - {% endfor %} - + +
From 3bb145a690321cb05facd0148c6012b87b3908cf Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Sun, 20 Sep 2026 18:26:09 +0200 Subject: [PATCH 7/7] Tie the clone form back to the action it posts to Review follow-ups: the action name the page posts is taken from the function again, so renaming it cannot leave the form posting an action that no longer exists (nothing renders this template in tests, so a wrong name would go unnoticed); the steps keep their qualified name, so a traceback names the step it came from rather than the decorator; and the `copied` keys say why they are spelled out instead of derived. --- backend/conferences/admin/actions.py | 6 +++++- backend/conferences/models/conference.py | 4 +++- .../templates/admin/conferences/clone_conference.html | 2 +- backend/conferences/workflows/clone_conference.py | 5 ++++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/backend/conferences/admin/actions.py b/backend/conferences/admin/actions.py index 2a3682e1a2..98274be111 100644 --- a/backend/conferences/admin/actions.py +++ b/backend/conferences/admin/actions.py @@ -140,7 +140,8 @@ def clone_conference(modeladmin, request, queryset): form = CloneConferenceForm(request.POST) if form.is_valid(): - return start_clone_conference(request, source, form.cleaned_data) + start_clone_conference(request, source, form.cleaned_data) + return None else: form = CloneConferenceForm(initial=next_edition_defaults(source)) @@ -154,6 +155,9 @@ def clone_conference(modeladmin, request, queryset): "media": modeladmin.media + form.media, "source": source, "form": form, + # The page posts back to the changelist, so it has to name the + # action; taken from the function so a rename cannot break it. + "action_name": clone_conference.__name__, }, ) diff --git a/backend/conferences/models/conference.py b/backend/conferences/models/conference.py index 07e64066b4..d93394cb53 100644 --- a/backend/conferences/models/conference.py +++ b/backend/conferences/models/conference.py @@ -124,7 +124,9 @@ class Conference(GeoLocalizedModel, TimeFramedModel, TimeStampedModel): _("max proposals per user"), null=True, blank=True, - help_text=_("Maximum number of proposals a user can submit. Leave empty for no limit."), + help_text=_( + "Maximum number of proposals a user can submit. Leave empty for no limit." + ), ) def get_slack_oauth_token(self): diff --git a/backend/conferences/templates/admin/conferences/clone_conference.html b/backend/conferences/templates/admin/conferences/clone_conference.html index 4d5c6599a0..6bbcb88a7f 100644 --- a/backend/conferences/templates/admin/conferences/clone_conference.html +++ b/backend/conferences/templates/admin/conferences/clone_conference.html @@ -27,7 +27,7 @@ {% csrf_token %} - +
diff --git a/backend/conferences/workflows/clone_conference.py b/backend/conferences/workflows/clone_conference.py index 62d97215c0..582500f23d 100644 --- a/backend/conferences/workflows/clone_conference.py +++ b/backend/conferences/workflows/clone_conference.py @@ -115,6 +115,7 @@ def step(ctx: Context, source_code: str, conference_id: int) -> int: return fn(source, conference) step.__name__ = fn.__name__ + step.__qualname__ = fn.__qualname__ step.__doc__ = fn.__doc__ return database_step(step) @@ -461,7 +462,9 @@ def copy_voting_included_events(source: Conference, conference: Conference) -> i # Everything that only needs the source and the freshly created conference, # in the order it is copied. Sponsor levels read the benefits copied just -# before them, so the order matters. +# before them, so the order matters. The names are what the workflow reports +# back as its `copied` counts, so they are spelled out rather than derived from +# the functions: renaming one should not change what callers read. COPY_STEPS = ( ("deadlines", copy_deadlines), ("durations", copy_durations),