Only one writeable campaign version at a time
This commit is contained in:
@@ -288,11 +288,11 @@ def fork_version_for_edit(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
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
|
A new working copy may be created only after the current version is
|
||||||
snapshots. This endpoint makes an explicit editable copy and makes that
|
permanently user-locked or delivery-final. Validation and temporary user
|
||||||
new copy the campaign's current version.
|
locks must be removed in place instead of creating parallel drafts.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
payload = payload or CampaignVersionUpdateRequest()
|
payload = payload or CampaignVersionUpdateRequest()
|
||||||
@@ -324,6 +324,8 @@ def fork_version_for_edit(
|
|||||||
campaign=CampaignResponse.model_validate(campaign),
|
campaign=CampaignResponse.model_validate(campaign),
|
||||||
version=CampaignVersionResponse.model_validate(version),
|
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:
|
except CampaignPersistenceError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,12 @@ def create_campaign_version_from_json(
|
|||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
session.flush()
|
session.flush()
|
||||||
else:
|
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.name = config.campaign.name
|
||||||
campaign.description = config.campaign.description
|
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
|
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:
|
def _version_is_validated_and_locked(version: CampaignVersion) -> bool:
|
||||||
validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {}
|
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))
|
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)
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
if not campaign or campaign.tenant_id != tenant_id:
|
if not campaign or campaign.tenant_id != tenant_id:
|
||||||
raise CampaignPersistenceError("Campaign version is not accessible for this tenant")
|
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 {
|
if _version_is_user_locked(version) or version.workflow_state in {
|
||||||
CampaignVersionWorkflowState.QUEUED.value,
|
CampaignVersionWorkflowState.QUEUED.value,
|
||||||
CampaignVersionWorkflowState.SENDING.value,
|
CampaignVersionWorkflowState.SENDING.value,
|
||||||
@@ -320,6 +345,7 @@ def build_campaign_version(
|
|||||||
campaign = session.get(Campaign, version.campaign_id)
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
if not campaign or campaign.tenant_id != tenant_id:
|
if not campaign or campaign.tenant_id != tenant_id:
|
||||||
raise CampaignPersistenceError("Campaign version is not accessible for this tenant")
|
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:
|
if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value:
|
||||||
raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt")
|
raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt")
|
||||||
validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {}
|
validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {}
|
||||||
|
|||||||
@@ -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:
|
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 {}
|
campaign_meta = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
|
||||||
if campaign_meta:
|
if campaign_meta:
|
||||||
@@ -279,17 +309,30 @@ def fork_campaign_version_for_edit(
|
|||||||
source_base_path: str | None = None,
|
source_base_path: str | None = None,
|
||||||
autosave: bool = True,
|
autosave: bool = True,
|
||||||
) -> CampaignVersion:
|
) -> 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
|
Validation and temporary user locks are still the active working version
|
||||||
users to continue editing a campaign. New content starts with the supplied
|
and must be unlocked in place. A copy is allowed only once the current
|
||||||
raw_json when provided, otherwise with a clone of the source version.
|
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)
|
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)
|
campaign = session.get(Campaign, campaign_id)
|
||||||
assert campaign is not None
|
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)
|
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)
|
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)
|
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)
|
campaign = session.get(Campaign, campaign_id)
|
||||||
assert campaign is not None
|
assert campaign is not None
|
||||||
|
ensure_current_working_version(campaign, version, action="unlock")
|
||||||
|
|
||||||
if is_temporary_user_locked_version(version):
|
if is_temporary_user_locked_version(version):
|
||||||
raise LockedCampaignVersionError("This version has a temporary user lock. Remove that lock before unlocking validation.")
|
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)
|
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)
|
campaign = session.get(Campaign, campaign_id)
|
||||||
assert campaign is not None
|
assert campaign is not None
|
||||||
|
ensure_current_working_version(campaign, version, action="edit")
|
||||||
|
|
||||||
if is_version_locked(version):
|
if is_version_locked(version):
|
||||||
raise LockedCampaignVersionError(
|
raise LockedCampaignVersionError(
|
||||||
@@ -511,6 +556,9 @@ def update_campaign_review_state(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version_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):
|
if is_version_final_locked(version):
|
||||||
raise LockedCampaignVersionError("Delivery has started; message review state can no longer be changed.")
|
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 {}
|
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,
|
campaign_id=campaign_id,
|
||||||
version_id=version_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):
|
if is_version_final_locked(version):
|
||||||
raise LockedCampaignVersionError("Delivery/final versions are permanently locked and cannot receive a temporary user lock.")
|
raise LockedCampaignVersionError("Delivery/final versions are permanently locked and cannot receive a temporary user lock.")
|
||||||
if is_permanent_user_locked_version(version):
|
if is_permanent_user_locked_version(version):
|
||||||
@@ -587,6 +638,9 @@ def unlock_user_locked_campaign_version(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version_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)
|
state = campaign_version_user_lock_state(version)
|
||||||
if state == USER_LOCK_PERMANENT:
|
if state == USER_LOCK_PERMANENT:
|
||||||
raise LockedCampaignVersionError("Permanently locked versions cannot be unlocked. Create an editable copy instead.")
|
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,
|
campaign_id=campaign_id,
|
||||||
version_id=version_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):
|
if is_version_final_locked(version):
|
||||||
raise LockedCampaignVersionError("This version is already permanently locked by its delivery/final state.")
|
raise LockedCampaignVersionError("This version is already permanently locked by its delivery/final state.")
|
||||||
if is_permanent_user_locked_version(version):
|
if is_permanent_user_locked_version(version):
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ def _get_current_version(session: Session, campaign: Campaign, version_id: str |
|
|||||||
version = session.get(CampaignVersion, wanted)
|
version = session.get(CampaignVersion, wanted)
|
||||||
if not version or version.campaign_id != campaign.id:
|
if not version or version.campaign_id != campaign.id:
|
||||||
raise QueueingError(f"Campaign version not found or not part of campaign: {wanted}")
|
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
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -221,6 +221,86 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(refused_unlock.status_code, 409, refused_unlock.text)
|
self.assertEqual(refused_unlock.status_code, 409, refused_unlock.text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_one_campaign_version_is_writable(self) -> 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:
|
def test_campaign_create_validate_build_and_mock_send(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
|
|||||||
Reference in New Issue
Block a user