From 0bb72541afdcb36c66bce2d7fcfe712fafb5e83d Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 13 Jun 2026 22:08:07 +0200 Subject: [PATCH] Only one writeable campaign version at a time --- server/app/api/v1/campaigns.py | 10 +-- server/app/mailer/persistence/campaigns.py | 26 +++++++ server/app/mailer/persistence/versions.py | 65 +++++++++++++++-- server/app/mailer/sending/jobs.py | 4 ++ server/multimailer-dev.db | Bin 1130496 -> 1134592 bytes server/tests/test_api_smoke.py | 80 +++++++++++++++++++++ 6 files changed, 177 insertions(+), 8 deletions(-) diff --git a/server/app/api/v1/campaigns.py b/server/app/api/v1/campaigns.py index 6749ccd..880bd5a 100644 --- a/server/app/api/v1/campaigns.py +++ b/server/app/api/v1/campaigns.py @@ -288,11 +288,11 @@ def fork_version_for_edit( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:write")), ): - """Create a new editable campaign version from a locked/validated/sent version. + """Create the campaign's next and only editable working version. - Versions that were validated, built, queued or sent are immutable audit - snapshots. This endpoint makes an explicit editable copy and makes that - new copy the campaign's current version. + A new working copy may be created only after the current version is + permanently user-locked or delivery-final. Validation and temporary user + locks must be removed in place instead of creating parallel drafts. """ payload = payload or CampaignVersionUpdateRequest() @@ -324,6 +324,8 @@ def fork_version_for_edit( campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version), ) + except LockedCampaignVersionError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc diff --git a/server/app/mailer/persistence/campaigns.py b/server/app/mailer/persistence/campaigns.py index 95d54ee..73aa829 100644 --- a/server/app/mailer/persistence/campaigns.py +++ b/server/app/mailer/persistence/campaigns.py @@ -135,6 +135,12 @@ def create_campaign_version_from_json( session.add(campaign) session.flush() else: + current = session.get(CampaignVersion, campaign.current_version_id) if campaign.current_version_id else None + if current and not _version_is_audit_safe_snapshot(current): + raise CampaignPersistenceError( + f"Campaign already has active working version #{current.version_number}. " + "Continue editing or unlock that version instead of importing a parallel draft." + ) campaign.name = config.campaign.name campaign.description = config.campaign.description @@ -168,6 +174,24 @@ def _version_is_user_locked(version: CampaignVersion) -> bool: return _version_user_lock_state(version) is not None +def _version_is_audit_safe_snapshot(version: CampaignVersion) -> bool: + return _version_user_lock_state(version) == "permanent" or version.workflow_state in { + CampaignVersionWorkflowState.QUEUED.value, + CampaignVersionWorkflowState.SENDING.value, + CampaignVersionWorkflowState.COMPLETED.value, + CampaignVersionWorkflowState.CANCELLED.value, + CampaignVersionWorkflowState.ARCHIVED.value, + } + + +def _ensure_current_campaign_version(campaign: Campaign, version: CampaignVersion, *, action: str) -> None: + if campaign.current_version_id != version.id: + raise CampaignPersistenceError( + f"Historical campaign versions are read-only and cannot be used to {action}. " + "Open the current working version instead." + ) + + def _version_is_validated_and_locked(version: CampaignVersion) -> bool: validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {} return bool(version.locked_at and validation_summary.get("ok") is True and not _version_is_user_locked(version)) @@ -203,6 +227,7 @@ def validate_campaign_version( campaign = session.get(Campaign, version.campaign_id) if not campaign or campaign.tenant_id != tenant_id: raise CampaignPersistenceError("Campaign version is not accessible for this tenant") + _ensure_current_campaign_version(campaign, version, action="validate") if _version_is_user_locked(version) or version.workflow_state in { CampaignVersionWorkflowState.QUEUED.value, CampaignVersionWorkflowState.SENDING.value, @@ -320,6 +345,7 @@ def build_campaign_version( campaign = session.get(Campaign, version.campaign_id) if not campaign or campaign.tenant_id != tenant_id: raise CampaignPersistenceError("Campaign version is not accessible for this tenant") + _ensure_current_campaign_version(campaign, version, action="build") if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value: raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt") validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {} diff --git a/server/app/mailer/persistence/versions.py b/server/app/mailer/persistence/versions.py index 0672ec0..1b97616 100644 --- a/server/app/mailer/persistence/versions.py +++ b/server/app/mailer/persistence/versions.py @@ -257,6 +257,36 @@ def is_version_locked(version: CampaignVersion) -> bool: ) +def ensure_current_working_version(campaign: Campaign, version: CampaignVersion, *, action: str = "modify") -> None: + """Require the campaign's single active working version. + + Historical versions remain reviewable, but they never become writable in + place. Continuing from immutable history must create a new working copy, + and that copy becomes the campaign's sole current version. + """ + + if campaign.current_version_id != version.id: + raise LockedCampaignVersionError( + f"Historical campaign versions are read-only and cannot be used to {action}. " + "Open the current working version instead." + ) + + +def campaign_has_active_working_version(session: Session, campaign: Campaign) -> bool: + """Return True while the campaign already has a non-final working version. + + Validation locks and temporary user locks are still the same working + version; they must be unlocked rather than forked into parallel drafts. + """ + + if not campaign.current_version_id: + return False + current = session.get(CampaignVersion, campaign.current_version_id) + if not current or current.campaign_id != campaign.id: + return False + return not is_audit_safe_version(current) + + def _apply_campaign_metadata(campaign: Campaign, raw_json: dict[str, Any]) -> None: campaign_meta = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {} if campaign_meta: @@ -279,17 +309,30 @@ def fork_campaign_version_for_edit( source_base_path: str | None = None, autosave: bool = True, ) -> CampaignVersion: - """Create a new editable working version from a locked/validated version. + """Create the next sole working version from immutable campaign history. - This preserves the audit value of the validated/sent version while allowing - users to continue editing a campaign. New content starts with the supplied - raw_json when provided, otherwise with a clone of the source version. + Validation and temporary user locks are still the active working version + and must be unlocked in place. A copy is allowed only once the current + version is permanently user-locked or delivery-final. """ source = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) campaign = session.get(Campaign, campaign_id) assert campaign is not None + if campaign_has_active_working_version(session, campaign): + current = session.get(CampaignVersion, campaign.current_version_id) + current_number = current.version_number if current else "current" + raise LockedCampaignVersionError( + f"Campaign already has active working version #{current_number}. " + "Unlock or continue editing that version instead of creating a parallel draft." + ) + if campaign.current_version_id and source.id != campaign.current_version_id: + raise LockedCampaignVersionError( + "Historical versions remain review-only and cannot become a new branch. " + "Create the next working copy from the campaign's current immutable version." + ) + base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json) runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json) @@ -381,6 +424,7 @@ def unlock_validated_campaign_version( version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) campaign = session.get(Campaign, campaign_id) assert campaign is not None + ensure_current_working_version(campaign, version, action="unlock") if is_temporary_user_locked_version(version): raise LockedCampaignVersionError("This version has a temporary user lock. Remove that lock before unlocking validation.") @@ -440,6 +484,7 @@ def update_campaign_version( version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) campaign = session.get(Campaign, campaign_id) assert campaign is not None + ensure_current_working_version(campaign, version, action="edit") if is_version_locked(version): raise LockedCampaignVersionError( @@ -511,6 +556,9 @@ def update_campaign_review_state( campaign_id=campaign_id, version_id=version_id, ) + campaign = session.get(Campaign, campaign_id) + assert campaign is not None + ensure_current_working_version(campaign, version, action="record review state for") if is_version_final_locked(version): raise LockedCampaignVersionError("Delivery has started; message review state can no longer be changed.") build_summary = version.build_summary if isinstance(version.build_summary, dict) else {} @@ -555,6 +603,9 @@ def lock_campaign_version_temporarily( campaign_id=campaign_id, version_id=version_id, ) + campaign = session.get(Campaign, campaign_id) + assert campaign is not None + ensure_current_working_version(campaign, version, action="lock") if is_version_final_locked(version): raise LockedCampaignVersionError("Delivery/final versions are permanently locked and cannot receive a temporary user lock.") if is_permanent_user_locked_version(version): @@ -587,6 +638,9 @@ def unlock_user_locked_campaign_version( campaign_id=campaign_id, version_id=version_id, ) + campaign = session.get(Campaign, campaign_id) + assert campaign is not None + ensure_current_working_version(campaign, version, action="unlock") state = campaign_version_user_lock_state(version) if state == USER_LOCK_PERMANENT: raise LockedCampaignVersionError("Permanently locked versions cannot be unlocked. Create an editable copy instead.") @@ -623,6 +677,9 @@ def permanently_lock_campaign_version( campaign_id=campaign_id, version_id=version_id, ) + campaign = session.get(Campaign, campaign_id) + assert campaign is not None + ensure_current_working_version(campaign, version, action="lock permanently") if is_version_final_locked(version): raise LockedCampaignVersionError("This version is already permanently locked by its delivery/final state.") if is_permanent_user_locked_version(version): diff --git a/server/app/mailer/sending/jobs.py b/server/app/mailer/sending/jobs.py index 0da7458..dc9111b 100644 --- a/server/app/mailer/sending/jobs.py +++ b/server/app/mailer/sending/jobs.py @@ -175,6 +175,10 @@ def _get_current_version(session: Session, campaign: Campaign, version_id: str | version = session.get(CampaignVersion, wanted) if not version or version.campaign_id != campaign.id: raise QueueingError(f"Campaign version not found or not part of campaign: {wanted}") + if campaign.current_version_id != version.id: + raise QueueingError( + "Historical campaign versions are review-only. Open the current working version before queueing or sending." + ) return version diff --git a/server/multimailer-dev.db b/server/multimailer-dev.db index 0370f6357ef14ac49c75463361617978efce68ff..f003e55d180ccb69e6a6d72f23198e6cea420ef0 100644 GIT binary patch delta 24438 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 delta 6592 zcmb7I349er(x0C0ndzRZU&4_9LIS+RBtXakxjuv!kz2wcK@ouH*b2XtNvBh)kjU` zE@_jS+oqtl-FS~Aq3V((^@7Ws8I>eA-ZLRFFX~V6V9TJ6f#_9NvHM z2vL6yJnz4YBE_>AL5w6PG*5A=?jr3Oy#(ud67hL@GzIc|(Ku5UmF;z}DxHJp zcSYPx8eLQ>p9xcz!t)2B>^cq8bU^M&-`SwyFJyq&tvK&cJW1ogL3l^_msay5{8Pu{ z!JN+y_r;?5;0lWpBg1e4n(A*W)qrh^h@Z)#Jks|vJRts)D=PR;g+T>pIGQ8MrYhB| zjduGmf6MJ#=QsN;(D^Dy3&r|gG+LmiS$Mi{Io|`8Rws{}z-j_wYV`5k4yYG&*OPO+{2Y9lF69pwitWo6=MS3|0D7Lf#ClD-IsYa zH~25V`Pz^XuEqeM<0&S)`OL1WIPnpQDk=pN)PLw9PvXqK1HCDM*N6FG0R4fBo19bw zP1hxUo$urOfLO-G^)OQ7AJvCEntK8Dmn43P7x5yHIKfFRoXSK{PfyKxKH^$_TjICf zFZBM*gEd@(y)kI$|7G~>6J28IKl?d+Q2&9WEO%Tzl4Nyv^lWm(E)4g1(X4DL^RL<;V?8}(4Vu14evjEl;%EV4;udG{zJiIgXntzE+k zvvM47iCSpDX!UfW�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 ul^S@GWPfO-7$@}k%+mI$eMtqL None: + headers, _ = self._login() + created = self.client.post( + "/api/v1/campaigns/new", + headers=headers, + json={"external_id": "single-working-version", "name": "Single working version"}, + ) + self.assertEqual(created.status_code, 200, created.text) + campaign_id = created.json()["campaign"]["id"] + first_version_id = created.json()["version"]["id"] + + parallel = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/fork", + headers=headers, + json={}, + ) + self.assertEqual(parallel.status_code, 409, parallel.text) + self.assertIn("active working version", parallel.text) + + permanent = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/lock-permanently", + headers=headers, + ) + self.assertEqual(permanent.status_code, 200, permanent.text) + + copied = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/fork", + headers=headers, + json={}, + ) + self.assertEqual(copied.status_code, 200, copied.text) + second_version_id = copied.json()["version"]["id"] + self.assertNotEqual(second_version_id, first_version_id) + self.assertEqual(copied.json()["campaign"]["current_version_id"], second_version_id) + + historical_update = self.client.put( + f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}", + headers=headers, + json={"current_step": "fields"}, + ) + self.assertEqual(historical_update.status_code, 409, historical_update.text) + self.assertIn("Historical campaign versions are read-only", historical_update.text) + + second_update = self.client.put( + f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}", + headers=headers, + json={"current_step": "fields"}, + ) + self.assertEqual(second_update.status_code, 200, second_update.text) + + second_parallel = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/fork", + headers=headers, + json={}, + ) + self.assertEqual(second_parallel.status_code, 409, second_parallel.text) + + second_permanent = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}/lock-permanently", + headers=headers, + ) + self.assertEqual(second_permanent.status_code, 200, second_permanent.text) + + historical_branch = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/fork", + headers=headers, + json={}, + ) + self.assertEqual(historical_branch.status_code, 409, historical_branch.text) + self.assertIn("cannot become a new branch", historical_branch.text) + + third = self.client.post( + f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}/fork", + headers=headers, + json={}, + ) + self.assertEqual(third.status_code, 200, third.text) + self.assertEqual(third.json()["version"]["version_number"], 3) + def test_campaign_create_validate_build_and_mock_send(self) -> None: headers, _ = self._login() campaign_json = {