campaign version refinment, user locks, db repair
This commit is contained in:
@@ -18,6 +18,7 @@ from app.api.v1.schemas import (
|
||||
CampaignVersionDetailResponse,
|
||||
CampaignVersionResponse,
|
||||
CampaignVersionSetStepRequest,
|
||||
CampaignReviewStateRequest,
|
||||
CampaignVersionUpdateRequest,
|
||||
CampaignPartialValidationRequest,
|
||||
CampaignPartialValidationResponse,
|
||||
@@ -49,9 +50,13 @@ from app.mailer.persistence.versions import (
|
||||
is_user_locked_version,
|
||||
is_version_locked,
|
||||
get_campaign_version_for_tenant,
|
||||
lock_campaign_version_temporarily,
|
||||
permanently_lock_campaign_version,
|
||||
publish_campaign_version,
|
||||
unlock_user_locked_campaign_version,
|
||||
unlock_validated_campaign_version,
|
||||
update_campaign_version,
|
||||
update_campaign_review_state,
|
||||
validate_campaign_partial,
|
||||
)
|
||||
|
||||
@@ -359,6 +364,98 @@ def unlock_version_validation(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/versions/{version_id}/lock-temporarily", response_model=CampaignVersionDetailResponse)
|
||||
def lock_version_temporarily(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
||||
):
|
||||
try:
|
||||
version = lock_campaign_version_temporarily(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
user_id=principal.user.id,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.version_user_locked_temporarily",
|
||||
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.post("/{campaign_id}/versions/{version_id}/unlock-user-lock", response_model=CampaignVersionDetailResponse)
|
||||
def unlock_version_user_lock(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
||||
):
|
||||
try:
|
||||
version = unlock_user_locked_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.version_user_lock_removed",
|
||||
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.post("/{campaign_id}/versions/{version_id}/lock-permanently", response_model=CampaignVersionDetailResponse)
|
||||
def lock_version_permanently(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
||||
):
|
||||
try:
|
||||
version = permanently_lock_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
user_id=principal.user.id,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.version_user_locked_permanently",
|
||||
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,
|
||||
@@ -468,6 +565,44 @@ def set_version_step(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/versions/{version_id}/review-state", response_model=CampaignVersionDetailResponse)
|
||||
def set_version_review_state(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignReviewStateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
||||
):
|
||||
try:
|
||||
version = update_campaign_review_state(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
inspection_complete=payload.inspection_complete,
|
||||
reviewed_message_keys=payload.reviewed_message_keys,
|
||||
user_id=principal.user.id,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.message_review_updated",
|
||||
object_type="campaign_version",
|
||||
object_id=version.id,
|
||||
details={
|
||||
"campaign_id": campaign_id,
|
||||
"inspection_complete": payload.inspection_complete,
|
||||
"reviewed_message_count": len(payload.reviewed_message_keys),
|
||||
},
|
||||
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_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/versions/{version_id}/validate-partial", response_model=CampaignPartialValidationResponse)
|
||||
def validate_version_partial(
|
||||
campaign_id: str,
|
||||
@@ -504,17 +639,25 @@ def publish_version(
|
||||
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
||||
):
|
||||
try:
|
||||
version = publish_campaign_version(session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id)
|
||||
version = publish_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
user_id=principal.user.id,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.version_published",
|
||||
action="campaign.version_user_locked_permanently",
|
||||
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
|
||||
|
||||
@@ -531,7 +674,7 @@ def validate_version(
|
||||
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.",
|
||||
detail="This version has a user lock or final delivery lock and cannot be validated. Remove a temporary lock or create an editable copy.",
|
||||
)
|
||||
result = validate_campaign_version(
|
||||
session,
|
||||
@@ -550,6 +693,8 @@ def validate_version(
|
||||
commit=True,
|
||||
)
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except CampaignPersistenceError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
@@ -589,13 +734,19 @@ def build_version(
|
||||
@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse)
|
||||
def list_jobs(
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
|
||||
):
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
query = session.query(CampaignJob).filter(CampaignJob.campaign_id == campaign.id)
|
||||
if version_id:
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if version.campaign_id != campaign.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found")
|
||||
query = query.filter(CampaignJob.campaign_version_id == version.id)
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.campaign_id == campaign.id)
|
||||
query
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.all()
|
||||
)
|
||||
@@ -603,6 +754,7 @@ def list_jobs(
|
||||
jobs=[
|
||||
{
|
||||
"id": job.id,
|
||||
"campaign_version_id": job.campaign_version_id,
|
||||
"entry_index": job.entry_index,
|
||||
"entry_id": job.entry_id,
|
||||
"recipient_email": job.recipient_email,
|
||||
@@ -620,6 +772,7 @@ def list_jobs(
|
||||
"sent_at": job.sent_at,
|
||||
"issues": job.issues_snapshot,
|
||||
"attachments": job.resolved_attachments,
|
||||
"resolved_recipients": job.resolved_recipients,
|
||||
}
|
||||
for job in jobs
|
||||
]
|
||||
|
||||
@@ -57,6 +57,13 @@ class CampaignVersionSetStepRequest(BaseModel):
|
||||
current_step: str
|
||||
|
||||
|
||||
class CampaignReviewStateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
inspection_complete: bool = False
|
||||
reviewed_message_keys: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CampaignPartialValidationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -82,6 +89,9 @@ class CampaignVersionResponse(BaseModel):
|
||||
published_at: datetime | None = None
|
||||
locked_at: datetime | None = None
|
||||
locked_by_user_id: str | None = None
|
||||
user_lock_state: Literal["temporary", "permanent"] | None = None
|
||||
user_locked_at: datetime | None = None
|
||||
user_locked_by_user_id: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
validation_summary: dict[str, Any] | None = None
|
||||
|
||||
Reference in New Issue
Block a user