diff --git a/README.md b/README.md index 17fee96b47..6d2fb698fb 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,20 @@ 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 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; 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 Repos used by this project are in separate repositories. diff --git a/backend/conferences/admin/actions.py b/backend/conferences/admin/actions.py index 3c8644cdb5..98274be111 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,128 @@ 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): + selected = list(queryset) + + if len(selected) != 1: + messages.error(request, "Select the single conference to use as base") + return None + + source = selected[0] + + if request.POST.get("apply"): + form = CloneConferenceForm(request.POST) + + if form.is_valid(): + start_clone_conference(request, source, form.cleaned_data) + return None + 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, + # 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__, + }, + ) + + +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}).", + ) 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/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 new file mode 100644 index 0000000000..6bbcb88a7f --- /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, 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. +
+ + +{% 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() 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..c89789af7e --- /dev/null +++ b/backend/conferences/tests/test_clone_conference_workflow.py @@ -0,0 +1,411 @@ +from datetime import date, datetime, time, 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 schedule.models import DayRoomThroughModel, Slot +from schedule.tests.factories import DayFactory, RoomFactory, SlotFactory +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, + 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 + sensitive database work runs back in this thread -- the one holding the + test transaction. + """ + + async def go(): + # ``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() + + try: + handle = resonate.run( + workflow_id or f"clone-{new_code}", + clone_conference, + source_code=source_code, + new_code=new_code, + new_name=new_name, + new_start=new_start.isoformat(), + new_end=new_end.isoformat(), + new_hostname=new_hostname, + ) + 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 + + +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/__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..582500f23d --- /dev/null +++ b/backend/conferences/workflows/clone_conference.py @@ -0,0 +1,517 @@ +"""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, 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 date, datetime, timedelta +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() + +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) + + +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.__qualname__ = fn.__qualname__ + step.__doc__ = fn.__doc__ + + return database_step(step) + + +@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.""" + 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 + + +@copy_step +def copy_deadlines(source: Conference, conference: Conference) -> int: + """Copy deadlines, shifted by the gap between the two editions' start dates.""" + 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 + + +@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) + + return copied + + +@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 + 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. + """ + 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 + + +@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 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 + + +@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 + + 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 + + +@copy_step +def copy_sponsor_special_options(source: Conference, conference: Conference) -> int: + 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 + + +@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} + + # 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 + + +@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) + ) + + +def copy_generic_copy(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) + + return copied + + +def copy_faqs(source: Conference, conference: Conference) -> int: + existing = { + i18n_key(faq.question) for faq in FAQ.objects.filter(conference=conference) + } + copied = 0 + + 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 + + return copied + + +def copy_menus(source: Conference, conference: Conference) -> int: + copied = 0 + + 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 + + +@copy_step +def copy_forms(source: Conference, conference: Conference) -> int: + """Copy forms and their questions; answers belong to the old edition.""" + 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 + + +@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) + + 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. 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), + ("days", copy_days), + ("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, + }