support versions, campaign structure change
This commit is contained in:
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.v1.schemas import (
|
||||
BuildCampaignRequest,
|
||||
CampaignCreateRequest,
|
||||
CampaignUpdateRequest,
|
||||
CampaignCreateResponse,
|
||||
CampaignCreateMinimalRequest,
|
||||
CampaignJobsResponse,
|
||||
@@ -34,9 +35,14 @@ from app.mailer.persistence.campaigns import (
|
||||
validate_campaign_version,
|
||||
)
|
||||
from app.mailer.persistence.versions import (
|
||||
LockedCampaignVersionError,
|
||||
create_minimal_campaign,
|
||||
fork_campaign_version_for_edit,
|
||||
is_version_final_locked,
|
||||
is_user_locked_version,
|
||||
get_campaign_version_for_tenant,
|
||||
publish_campaign_version,
|
||||
unlock_validated_campaign_version,
|
||||
update_campaign_version,
|
||||
validate_campaign_partial,
|
||||
)
|
||||
@@ -152,6 +158,50 @@ def get_campaign(
|
||||
return CampaignResponse.model_validate(_get_campaign_for_tenant(session, campaign_id, principal.tenant_id))
|
||||
|
||||
|
||||
@router.put("/{campaign_id}", response_model=CampaignResponse)
|
||||
def update_campaign_metadata_endpoint(
|
||||
campaign_id: str,
|
||||
payload: CampaignUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
||||
):
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
if payload.external_id is not None:
|
||||
value = payload.external_id.strip()
|
||||
if not value:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign ID cannot be empty")
|
||||
duplicate = (
|
||||
session.query(Campaign)
|
||||
.filter(Campaign.tenant_id == principal.tenant_id, Campaign.external_id == value, Campaign.id != campaign.id)
|
||||
.one_or_none()
|
||||
)
|
||||
if duplicate:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Campaign ID already exists for this tenant")
|
||||
campaign.external_id = value
|
||||
if payload.name is not None:
|
||||
value = payload.name.strip()
|
||||
if not value:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign name cannot be empty")
|
||||
campaign.name = value
|
||||
if payload.status is not None:
|
||||
campaign.status = payload.status
|
||||
if payload.description is not None:
|
||||
campaign.description = payload.description
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
session.refresh(campaign)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.metadata_updated",
|
||||
object_type="campaign",
|
||||
object_id=campaign.id,
|
||||
details={"external_id": campaign.external_id, "name": campaign.name},
|
||||
commit=True,
|
||||
)
|
||||
return CampaignResponse.model_validate(campaign)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse])
|
||||
def list_versions(
|
||||
campaign_id: str,
|
||||
@@ -184,6 +234,90 @@ def get_version_detail(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/versions/{version_id}/fork", response_model=CampaignCreateResponse)
|
||||
def fork_version_for_edit(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignVersionUpdateRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
||||
):
|
||||
"""Create a new editable campaign version from a locked/validated/sent 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.
|
||||
"""
|
||||
|
||||
payload = payload or CampaignVersionUpdateRequest()
|
||||
try:
|
||||
version = fork_campaign_version_for_edit(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
raw_json=payload.campaign_json,
|
||||
current_flow=payload.current_flow or "manual",
|
||||
current_step=payload.current_step,
|
||||
editor_state=payload.editor_state,
|
||||
source_filename=payload.source_filename,
|
||||
source_base_path=payload.source_base_path,
|
||||
autosave=True,
|
||||
)
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.version_forked_for_edit",
|
||||
object_type="campaign_version",
|
||||
object_id=version.id,
|
||||
details={"campaign_id": campaign_id, "source_version_id": version_id, "version_number": version.version_number},
|
||||
commit=True,
|
||||
)
|
||||
return CampaignCreateResponse(
|
||||
campaign=CampaignResponse.model_validate(campaign),
|
||||
version=CampaignVersionResponse.model_validate(version),
|
||||
)
|
||||
except CampaignPersistenceError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/versions/{version_id}/unlock-validation", response_model=CampaignVersionDetailResponse)
|
||||
def unlock_version_validation(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
||||
):
|
||||
"""Unlock a successfully validated version before delivery starts.
|
||||
|
||||
Unlocking invalidates validation/build state and removes generated jobs for
|
||||
that version. Sent/final versions cannot be unlocked and must be copied.
|
||||
"""
|
||||
|
||||
try:
|
||||
version = unlock_validated_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.version_validation_unlocked",
|
||||
object_type="campaign_version",
|
||||
object_id=version.id,
|
||||
details={"campaign_id": campaign_id},
|
||||
commit=True,
|
||||
)
|
||||
return CampaignVersionDetailResponse.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
|
||||
|
||||
|
||||
@router.put("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse)
|
||||
def update_version_detail(
|
||||
campaign_id: str,
|
||||
@@ -218,6 +352,8 @@ def update_version_detail(
|
||||
commit=True,
|
||||
)
|
||||
return CampaignVersionDetailResponse.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
|
||||
except Exception as exc:
|
||||
@@ -258,6 +394,8 @@ def autosave_version(
|
||||
commit=True,
|
||||
)
|
||||
return CampaignVersionDetailResponse.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
|
||||
except Exception as exc:
|
||||
@@ -283,6 +421,8 @@ def set_version_step(
|
||||
autosave=True,
|
||||
)
|
||||
return CampaignVersionDetailResponse.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
|
||||
|
||||
@@ -346,6 +486,12 @@ def validate_version(
|
||||
principal: ApiPrincipal = Depends(require_scope("campaign:validate")),
|
||||
):
|
||||
try:
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if is_user_locked_version(version) or is_version_final_locked(version):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="This version is audit-safe/final and cannot be validated again. Create an editable copy instead.",
|
||||
)
|
||||
result = validate_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
@@ -608,25 +754,23 @@ def send_campaign_now_endpoint(
|
||||
if not version_id:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign has no current version")
|
||||
|
||||
validation_result: dict[str, object] | None = None
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
validation_result: dict[str, object] | None = version.validation_summary if isinstance(version.validation_summary, dict) else None
|
||||
build_result: dict[str, object] | None = None
|
||||
if payload.validate_before_send:
|
||||
validation_result = validate_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
version_id=version_id,
|
||||
check_files=payload.check_files,
|
||||
user_id=principal.user.id,
|
||||
if is_user_locked_version(version):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="User-locked audit-safe versions cannot be dry-run or sent. Create an editable copy and validate it instead.",
|
||||
)
|
||||
if not validation_result.get("ok"):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={"message": "Campaign validation failed", "validation": validation_result})
|
||||
|
||||
if payload.build_before_send:
|
||||
build_result = build_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
version_id=version_id,
|
||||
write_eml=True,
|
||||
if not version.locked_at or not validation_result or validation_result.get("ok") is not True:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Campaign version must be validated and locked before dry-run or sending.",
|
||||
)
|
||||
if not version.build_summary:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Campaign version must be built before dry-run or sending.",
|
||||
)
|
||||
|
||||
result = send_campaign_now(
|
||||
|
||||
@@ -16,6 +16,17 @@ class CampaignCreateRequest(BaseModel):
|
||||
source_base_path: str | None = None
|
||||
|
||||
|
||||
|
||||
|
||||
class CampaignUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
external_id: str | None = None
|
||||
name: str | None = None
|
||||
status: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class CampaignCreateMinimalRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -205,8 +216,8 @@ class SendCampaignNowRequest(BaseModel):
|
||||
version_id: str | None = None
|
||||
include_warnings: bool = True
|
||||
check_files: bool = False
|
||||
validate_before_send: bool = True
|
||||
build_before_send: bool = True
|
||||
validate_before_send: bool = False
|
||||
build_before_send: bool = False
|
||||
dry_run: bool = False
|
||||
use_rate_limit: bool = True
|
||||
enqueue_imap_task: bool = False
|
||||
|
||||
Reference in New Issue
Block a user