From 75223b53aa44a56934de9b80cea4e602107be55b Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Sat, 7 Nov 2026 02:57:14 +0100 Subject: [PATCH 1/2] Clone invitation letter config data --- .../tests/test_clone_conference_workflow.py | 72 +++++++++++++++++++ .../conferences/workflows/clone_conference.py | 64 ++++++++++++++++- 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/backend/conferences/tests/test_clone_conference_workflow.py b/backend/conferences/tests/test_clone_conference_workflow.py index c89789af7e..096f5ab9a4 100644 --- a/backend/conferences/tests/test_clone_conference_workflow.py +++ b/backend/conferences/tests/test_clone_conference_workflow.py @@ -43,6 +43,18 @@ SponsorSpecialOptionFactory, ) from submissions.tests.factories import SubmissionTypeFactory +from visa.models import ( + InvitationLetterAsset, + InvitationLetterConferenceConfig, + InvitationLetterDocument, + InvitationLetterDocumentInclusionPolicy, +) +from visa.tests.factories import ( + InvitationLetterAssetFactory, + InvitationLetterConferenceConfigFactory, + InvitationLetterDocumentFactory, + InvitationLetterDynamicDocumentFactory, +) from voting.models import IncludedEvent from voting.tests.factories.included_event import IncludedEventFactory @@ -294,11 +306,59 @@ def test_copies_voting_included_events(source_conference): ).exists() +def test_copies_the_invitation_letter_config(source_conference): + config = InvitationLetterConferenceConfigFactory(conference=source_conference) + asset = InvitationLetterAssetFactory( + invitation_letter_conference_config=config, identifier="logo" + ) + letter = InvitationLetterDynamicDocumentFactory( + invitation_letter_conference_config=config, name="Letter" + ) + hotel = InvitationLetterDocumentFactory( + invitation_letter_conference_config=config, + name="Hotel booking", + inclusion_policy=InvitationLetterDocumentInclusionPolicy.GRANT_ACCOMMODATION, + ) + + result = run_clone(source_conference.code) + + new_config = InvitationLetterConferenceConfig.objects.get( + conference__code="pycon2027" + ) + new_asset = new_config.assets.get() + new_letter, new_hotel = new_config.attached_documents.order_by("order") + + assert result["copied"]["invitation_letter_config"] == 4 + assert new_asset.identifier == "logo" + assert new_asset.image.name == asset.image.name + assert new_letter.name == "Letter" + assert new_letter.dynamic_document == letter.dynamic_document + assert not new_letter.document + assert new_hotel.name == "Hotel booking" + assert ( + new_hotel.inclusion_policy + == InvitationLetterDocumentInclusionPolicy.GRANT_ACCOMMODATION + ) + assert new_hotel.document.name.startswith( + "invitation_letter_attached_documents/pycon2027/" + ) + assert new_hotel.document.read() == hotel.document.open("rb").read() + + 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") + invitation_letter_config = InvitationLetterConferenceConfigFactory( + conference=source_conference + ) + InvitationLetterAssetFactory( + invitation_letter_conference_config=invitation_letter_config + ) + InvitationLetterDocumentFactory( + invitation_letter_conference_config=invitation_letter_config, name="Letter" + ) first = run_clone(source_conference.code, workflow_id="clone-first-run") second = run_clone(source_conference.code, workflow_id="clone-second-run") @@ -309,6 +369,18 @@ def test_running_the_workflow_again_does_not_duplicate_anything(source_conferenc 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 ( + InvitationLetterAsset.objects.filter( + invitation_letter_conference_config__conference__code="pycon2027" + ).count() + == 1 + ) + assert ( + InvitationLetterDocument.objects.filter( + invitation_letter_conference_config__conference__code="pycon2027" + ).count() + == 1 + ) assert all(count == 0 for count in second["copied"].values()) diff --git a/backend/conferences/workflows/clone_conference.py b/backend/conferences/workflows/clone_conference.py index 582500f23d..aada44a8fd 100644 --- a/backend/conferences/workflows/clone_conference.py +++ b/backend/conferences/workflows/clone_conference.py @@ -3,7 +3,7 @@ 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 +menus, copy, forms, the invitation letter documents. 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. @@ -15,9 +15,11 @@ from __future__ import annotations import json +import os from datetime import date, datetime, timedelta from typing import Any, Callable +from django.core.files.base import ContentFile from django.db import transaction from resonate.context import Context @@ -33,6 +35,11 @@ SponsorLevelBenefit, SponsorSpecialOption, ) +from visa.models import ( + InvitationLetterAsset, + InvitationLetterConferenceConfig, + InvitationLetterDocument, +) from voting.models import IncludedEvent resonate = get_resonate() @@ -460,6 +467,60 @@ def copy_voting_included_events(source: Conference, conference: Conference) -> i return copied +@copy_step +def copy_invitation_letter_config(source: Conference, conference: Conference) -> int: + """Copy the invitation letter config with its assets and attached documents. + + Assets point at the source edition's stored image. Document files are + stored under the conference code, so each edition gets its own copy. + """ + source_config = InvitationLetterConferenceConfig.objects.filter( + conference=source + ).first() + + if source_config is None: + return 0 + + config, created = InvitationLetterConferenceConfig.objects.get_or_create( + conference=conference + ) + copied = int(created) + + for asset in source_config.assets.order_by("id"): + _, asset_created = InvitationLetterAsset.objects.get_or_create( + invitation_letter_conference_config=config, + identifier=asset.identifier, + defaults={"image": asset.image.name}, + ) + copied += int(asset_created) + + existing_documents = set(config.attached_documents.values_list("name", flat=True)) + + for document in source_config.attached_documents.order_by("order"): + if document.name in existing_documents: + continue + + new_document = InvitationLetterDocument( + invitation_letter_conference_config=config, + name=document.name, + inclusion_policy=document.inclusion_policy, + dynamic_document=document.dynamic_document, + ) + + if document.document: + with document.document.open("rb") as file: + new_document.document.save( + os.path.basename(document.document.name), + ContentFile(file.read()), + save=False, + ) + + new_document.save() + copied += 1 + + 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 @@ -476,6 +537,7 @@ def copy_voting_included_events(source: Conference, conference: Conference) -> i ("cms_content", copy_cms_content), ("forms", copy_forms), ("voting_included_events", copy_voting_included_events), + ("invitation_letter_config", copy_invitation_letter_config), ) From cb95abd671b89598812b14ed2b3211ae09ceb26f Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Sat, 7 Nov 2026 03:04:58 +0100 Subject: [PATCH 2/2] Point cloned invitation letter documents at existing files Copied documents now reference the source edition's stored file instead of uploading a copy, matching how assets are handled. Documents are de-duplicated with get_or_create on config and name. --- .../tests/test_clone_conference_workflow.py | 22 +++-------- .../conferences/workflows/clone_conference.py | 39 ++++++------------- 2 files changed, 17 insertions(+), 44 deletions(-) diff --git a/backend/conferences/tests/test_clone_conference_workflow.py b/backend/conferences/tests/test_clone_conference_workflow.py index 096f5ab9a4..ba9c063864 100644 --- a/backend/conferences/tests/test_clone_conference_workflow.py +++ b/backend/conferences/tests/test_clone_conference_workflow.py @@ -44,9 +44,7 @@ ) from submissions.tests.factories import SubmissionTypeFactory from visa.models import ( - InvitationLetterAsset, InvitationLetterConferenceConfig, - InvitationLetterDocument, InvitationLetterDocumentInclusionPolicy, ) from visa.tests.factories import ( @@ -339,10 +337,7 @@ def test_copies_the_invitation_letter_config(source_conference): new_hotel.inclusion_policy == InvitationLetterDocumentInclusionPolicy.GRANT_ACCOMMODATION ) - assert new_hotel.document.name.startswith( - "invitation_letter_attached_documents/pycon2027/" - ) - assert new_hotel.document.read() == hotel.document.open("rb").read() + assert new_hotel.document.name == hotel.document.name def test_running_the_workflow_again_does_not_duplicate_anything(source_conference): @@ -369,18 +364,11 @@ def test_running_the_workflow_again_does_not_duplicate_anything(source_conferenc 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 ( - InvitationLetterAsset.objects.filter( - invitation_letter_conference_config__conference__code="pycon2027" - ).count() - == 1 - ) - assert ( - InvitationLetterDocument.objects.filter( - invitation_letter_conference_config__conference__code="pycon2027" - ).count() - == 1 + new_invitation_letter_config = InvitationLetterConferenceConfig.objects.get( + conference__code="pycon2027" ) + assert new_invitation_letter_config.assets.count() == 1 + assert new_invitation_letter_config.attached_documents.count() == 1 assert all(count == 0 for count in second["copied"].values()) diff --git a/backend/conferences/workflows/clone_conference.py b/backend/conferences/workflows/clone_conference.py index aada44a8fd..373f2c7579 100644 --- a/backend/conferences/workflows/clone_conference.py +++ b/backend/conferences/workflows/clone_conference.py @@ -3,10 +3,10 @@ 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 invitation letter documents. 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. +menus, copy, forms, invitation letter documents. 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. @@ -15,11 +15,9 @@ from __future__ import annotations import json -import os from datetime import date, datetime, timedelta from typing import Any, Callable -from django.core.files.base import ContentFile from django.db import transaction from resonate.context import Context @@ -471,8 +469,7 @@ def copy_voting_included_events(source: Conference, conference: Conference) -> i def copy_invitation_letter_config(source: Conference, conference: Conference) -> int: """Copy the invitation letter config with its assets and attached documents. - Assets point at the source edition's stored image. Document files are - stored under the conference code, so each edition gets its own copy. + Assets and documents point at the source edition's stored files. """ source_config = InvitationLetterConferenceConfig.objects.filter( conference=source @@ -494,29 +491,17 @@ def copy_invitation_letter_config(source: Conference, conference: Conference) -> ) copied += int(asset_created) - existing_documents = set(config.attached_documents.values_list("name", flat=True)) - for document in source_config.attached_documents.order_by("order"): - if document.name in existing_documents: - continue - - new_document = InvitationLetterDocument( + _, document_created = InvitationLetterDocument.objects.get_or_create( invitation_letter_conference_config=config, name=document.name, - inclusion_policy=document.inclusion_policy, - dynamic_document=document.dynamic_document, + defaults={ + "inclusion_policy": document.inclusion_policy, + "dynamic_document": document.dynamic_document, + "document": document.document.name, + }, ) - - if document.document: - with document.document.open("rb") as file: - new_document.document.save( - os.path.basename(document.document.name), - ContentFile(file.read()), - save=False, - ) - - new_document.save() - copied += 1 + copied += int(document_created) return copied