campaign version refinment, user locks, db repair
This commit is contained in:
@@ -61,6 +61,8 @@ CELERY_PREFETCH_MULTIPLIER=1
|
|||||||
CELERY_MAX_TASKS_PER_CHILD=200
|
CELERY_MAX_TASKS_PER_CHILD=200
|
||||||
CELERY_LOGLEVEL=INFO
|
CELERY_LOGLEVEL=INFO
|
||||||
|
|
||||||
|
RUN_DB_MIGRATIONS=true
|
||||||
|
|
||||||
# Existing Traefik/proxy network example
|
# Existing Traefik/proxy network example
|
||||||
EXTERNAL_PROXY_NETWORK=proxy
|
EXTERNAL_PROXY_NETWORK=proxy
|
||||||
TRAEFIK_ENTRYPOINT=websecure
|
TRAEFIK_ENTRYPOINT=websecure
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ from app.db import models # noqa: F401 - ensure models are imported
|
|||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
database_url = config.attributes.get("database_url") or settings.database_url
|
||||||
|
config.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""explicit temporary and permanent user locks
|
||||||
|
|
||||||
|
Revision ID: 5f6a7b8c9d0e
|
||||||
|
Revises: 4e5f6a7b8c9d
|
||||||
|
Create Date: 2026-06-13 18:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "5f6a7b8c9d0e"
|
||||||
|
down_revision: Union[str, None] = "4e5f6a7b8c9d"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("campaign_versions") as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("user_lock_state", sa.String(length=20), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column("user_locked_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column("user_locked_by_user_id", sa.String(length=36), nullable=True))
|
||||||
|
batch_op.create_foreign_key(
|
||||||
|
"fk_campaign_versions_user_locked_by_user_id_users",
|
||||||
|
"users",
|
||||||
|
["user_locked_by_user_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
batch_op.create_index("ix_campaign_versions_user_lock_state", ["user_lock_state"])
|
||||||
|
batch_op.create_index("ix_campaign_versions_user_locked_by_user_id", ["user_locked_by_user_id"])
|
||||||
|
|
||||||
|
# Existing published snapshots were the former irreversible user lock.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE campaign_versions
|
||||||
|
SET user_lock_state = 'permanent',
|
||||||
|
user_locked_at = published_at,
|
||||||
|
user_locked_by_user_id = NULL
|
||||||
|
WHERE published_at IS NOT NULL
|
||||||
|
AND user_lock_state IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("campaign_versions") as batch_op:
|
||||||
|
batch_op.drop_index("ix_campaign_versions_user_locked_by_user_id")
|
||||||
|
batch_op.drop_index("ix_campaign_versions_user_lock_state")
|
||||||
|
batch_op.drop_constraint("fk_campaign_versions_user_locked_by_user_id_users", type_="foreignkey")
|
||||||
|
batch_op.drop_column("user_locked_by_user_id")
|
||||||
|
batch_op.drop_column("user_locked_at")
|
||||||
|
batch_op.drop_column("user_lock_state")
|
||||||
@@ -18,6 +18,7 @@ from app.api.v1.schemas import (
|
|||||||
CampaignVersionDetailResponse,
|
CampaignVersionDetailResponse,
|
||||||
CampaignVersionResponse,
|
CampaignVersionResponse,
|
||||||
CampaignVersionSetStepRequest,
|
CampaignVersionSetStepRequest,
|
||||||
|
CampaignReviewStateRequest,
|
||||||
CampaignVersionUpdateRequest,
|
CampaignVersionUpdateRequest,
|
||||||
CampaignPartialValidationRequest,
|
CampaignPartialValidationRequest,
|
||||||
CampaignPartialValidationResponse,
|
CampaignPartialValidationResponse,
|
||||||
@@ -49,9 +50,13 @@ from app.mailer.persistence.versions import (
|
|||||||
is_user_locked_version,
|
is_user_locked_version,
|
||||||
is_version_locked,
|
is_version_locked,
|
||||||
get_campaign_version_for_tenant,
|
get_campaign_version_for_tenant,
|
||||||
|
lock_campaign_version_temporarily,
|
||||||
|
permanently_lock_campaign_version,
|
||||||
publish_campaign_version,
|
publish_campaign_version,
|
||||||
|
unlock_user_locked_campaign_version,
|
||||||
unlock_validated_campaign_version,
|
unlock_validated_campaign_version,
|
||||||
update_campaign_version,
|
update_campaign_version,
|
||||||
|
update_campaign_review_state,
|
||||||
validate_campaign_partial,
|
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
|
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)
|
@router.put("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse)
|
||||||
def update_version_detail(
|
def update_version_detail(
|
||||||
campaign_id: str,
|
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
|
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)
|
@router.post("/{campaign_id}/versions/{version_id}/validate-partial", response_model=CampaignPartialValidationResponse)
|
||||||
def validate_version_partial(
|
def validate_version_partial(
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
@@ -504,17 +639,25 @@ def publish_version(
|
|||||||
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
||||||
):
|
):
|
||||||
try:
|
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(
|
audit_from_principal(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
action="campaign.version_published",
|
action="campaign.version_user_locked_permanently",
|
||||||
object_type="campaign_version",
|
object_type="campaign_version",
|
||||||
object_id=version.id,
|
object_id=version.id,
|
||||||
details={"campaign_id": campaign_id},
|
details={"campaign_id": campaign_id},
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
return CampaignVersionDetailResponse.model_validate(version)
|
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:
|
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
|
||||||
|
|
||||||
@@ -531,7 +674,7 @@ def validate_version(
|
|||||||
if is_user_locked_version(version) or is_version_final_locked(version):
|
if is_user_locked_version(version) or is_version_final_locked(version):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
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(
|
result = validate_campaign_version(
|
||||||
session,
|
session,
|
||||||
@@ -550,6 +693,8 @@ def validate_version(
|
|||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
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
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -589,13 +734,19 @@ def build_version(
|
|||||||
@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse)
|
@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse)
|
||||||
def list_jobs(
|
def list_jobs(
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
|
version_id: str | None = None,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
|
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
|
||||||
):
|
):
|
||||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
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 = (
|
jobs = (
|
||||||
session.query(CampaignJob)
|
query
|
||||||
.filter(CampaignJob.campaign_id == campaign.id)
|
|
||||||
.order_by(CampaignJob.entry_index.asc())
|
.order_by(CampaignJob.entry_index.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
@@ -603,6 +754,7 @@ def list_jobs(
|
|||||||
jobs=[
|
jobs=[
|
||||||
{
|
{
|
||||||
"id": job.id,
|
"id": job.id,
|
||||||
|
"campaign_version_id": job.campaign_version_id,
|
||||||
"entry_index": job.entry_index,
|
"entry_index": job.entry_index,
|
||||||
"entry_id": job.entry_id,
|
"entry_id": job.entry_id,
|
||||||
"recipient_email": job.recipient_email,
|
"recipient_email": job.recipient_email,
|
||||||
@@ -620,6 +772,7 @@ def list_jobs(
|
|||||||
"sent_at": job.sent_at,
|
"sent_at": job.sent_at,
|
||||||
"issues": job.issues_snapshot,
|
"issues": job.issues_snapshot,
|
||||||
"attachments": job.resolved_attachments,
|
"attachments": job.resolved_attachments,
|
||||||
|
"resolved_recipients": job.resolved_recipients,
|
||||||
}
|
}
|
||||||
for job in jobs
|
for job in jobs
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ class CampaignVersionSetStepRequest(BaseModel):
|
|||||||
current_step: str
|
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):
|
class CampaignPartialValidationRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -82,6 +89,9 @@ class CampaignVersionResponse(BaseModel):
|
|||||||
published_at: datetime | None = None
|
published_at: datetime | None = None
|
||||||
locked_at: datetime | None = None
|
locked_at: datetime | None = None
|
||||||
locked_by_user_id: str | 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
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
validation_summary: dict[str, Any] | None = None
|
validation_summary: dict[str, Any] | None = None
|
||||||
|
|||||||
27
server/app/db/migrate.py
Normal file
27
server/app/db/migrate.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from app.db.migrations import migrate_database
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Upgrade the Multi Seal Mail database schema")
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-reconcile-legacy-schema",
|
||||||
|
action="store_true",
|
||||||
|
help="Disable repair of the known create_all/Alembic development schema drift",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
result = migrate_database(reconcile_legacy_schema=not args.no_reconcile_legacy_schema)
|
||||||
|
if result.reconciled_revision:
|
||||||
|
print(
|
||||||
|
"Reconciled legacy database marker "
|
||||||
|
f"from {result.previous_revision or 'unversioned'} to {result.reconciled_revision}."
|
||||||
|
)
|
||||||
|
print(f"Database schema is at revision {result.current_revision or 'unknown'}.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
163
server/app/db/migrations.py
Normal file
163
server/app/db/migrations.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
|
||||||
|
from app.settings import settings
|
||||||
|
|
||||||
|
# Historic development databases could be created partly through Alembic and
|
||||||
|
# partly through Base.metadata.create_all(). In that state Alembic still says
|
||||||
|
# "2c..." while the 3d/4e file-storage tables already exist, so a normal
|
||||||
|
# upgrade attempts to create file_blobs again. This reconciliation is kept
|
||||||
|
# deliberately narrow and only advances the marker when the complete expected
|
||||||
|
# schema for the skipped revisions is already present.
|
||||||
|
REVISION_AUTH_RBAC = "2c3d4e5f6a7b"
|
||||||
|
REVISION_FILE_STORAGE = "3d4e5f6a7b8c"
|
||||||
|
REVISION_FILE_FOLDERS = "4e5f6a7b8c9d"
|
||||||
|
|
||||||
|
_FILE_STORAGE_TABLES = {
|
||||||
|
"file_blobs",
|
||||||
|
"file_assets",
|
||||||
|
"file_versions",
|
||||||
|
"file_shares",
|
||||||
|
"campaign_attachment_uses",
|
||||||
|
}
|
||||||
|
_FILE_FOLDER_TABLES = {"file_folders"}
|
||||||
|
|
||||||
|
_FILE_STORAGE_COLUMNS = {
|
||||||
|
"file_blobs": {
|
||||||
|
"id",
|
||||||
|
"tenant_id",
|
||||||
|
"storage_backend",
|
||||||
|
"storage_key",
|
||||||
|
"checksum_sha256",
|
||||||
|
"size_bytes",
|
||||||
|
},
|
||||||
|
"file_assets": {
|
||||||
|
"id",
|
||||||
|
"tenant_id",
|
||||||
|
"owner_type",
|
||||||
|
"display_path",
|
||||||
|
"filename",
|
||||||
|
"current_version_id",
|
||||||
|
},
|
||||||
|
"file_versions": {
|
||||||
|
"id",
|
||||||
|
"file_asset_id",
|
||||||
|
"blob_id",
|
||||||
|
"version_number",
|
||||||
|
"checksum_sha256",
|
||||||
|
},
|
||||||
|
"file_shares": {"id", "file_asset_id", "target_type", "target_id", "permission"},
|
||||||
|
"campaign_attachment_uses": {
|
||||||
|
"id",
|
||||||
|
"campaign_id",
|
||||||
|
"campaign_version_id",
|
||||||
|
"file_asset_id",
|
||||||
|
"file_version_id",
|
||||||
|
"file_blob_id",
|
||||||
|
},
|
||||||
|
"file_folders": {"id", "tenant_id", "owner_type", "path"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MigrationResult:
|
||||||
|
previous_revision: str | None
|
||||||
|
reconciled_revision: str | None
|
||||||
|
current_revision: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def alembic_config(*, database_url: str | None = None) -> Config:
|
||||||
|
server_root = Path(__file__).resolve().parents[2]
|
||||||
|
config = Config(str(server_root / "alembic.ini"))
|
||||||
|
config.set_main_option("script_location", str(server_root / "alembic"))
|
||||||
|
config.attributes["database_url"] = database_url or settings.database_url
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def database_revision(database_url: str | None = None) -> str | None:
|
||||||
|
url = database_url or settings.database_url
|
||||||
|
engine = create_engine(url)
|
||||||
|
try:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
return MigrationContext.configure(connection).get_current_revision()
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_columns(inspector, table_name: str, required: set[str]) -> bool:
|
||||||
|
try:
|
||||||
|
actual = {column["name"] for column in inspector.get_columns(table_name)}
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return required.issubset(actual)
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_legacy_create_all_schema(database_url: str | None = None) -> str | None:
|
||||||
|
"""Repair the known Alembic/create_all drift without modifying table data.
|
||||||
|
|
||||||
|
Returns the revision stamped during reconciliation, or ``None`` when no
|
||||||
|
repair was necessary. A partial/unknown schema is intentionally left alone
|
||||||
|
so Alembic can fail visibly instead of guessing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = database_url or settings.database_url
|
||||||
|
engine = create_engine(url)
|
||||||
|
try:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
current = MigrationContext.configure(connection).get_current_revision()
|
||||||
|
schema = inspect(connection)
|
||||||
|
tables = set(schema.get_table_names())
|
||||||
|
|
||||||
|
has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all(
|
||||||
|
_has_columns(schema, table, _FILE_STORAGE_COLUMNS[table])
|
||||||
|
for table in _FILE_STORAGE_TABLES
|
||||||
|
)
|
||||||
|
has_file_folders = _FILE_FOLDER_TABLES.issubset(tables) and _has_columns(
|
||||||
|
schema,
|
||||||
|
"file_folders",
|
||||||
|
_FILE_STORAGE_COLUMNS["file_folders"],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
target: str | None = None
|
||||||
|
if current == REVISION_AUTH_RBAC and has_file_storage and has_file_folders:
|
||||||
|
target = REVISION_FILE_FOLDERS
|
||||||
|
elif current == REVISION_AUTH_RBAC and has_file_storage:
|
||||||
|
target = REVISION_FILE_STORAGE
|
||||||
|
elif current == REVISION_FILE_STORAGE and has_file_folders:
|
||||||
|
target = REVISION_FILE_FOLDERS
|
||||||
|
elif current is None and has_file_storage and has_file_folders:
|
||||||
|
# This is the other create_all-only development shape. The strict
|
||||||
|
# column checks above ensure that we only stamp a complete known schema.
|
||||||
|
target = REVISION_FILE_FOLDERS
|
||||||
|
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
command.stamp(alembic_config(database_url=url), target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database(
|
||||||
|
*,
|
||||||
|
database_url: str | None = None,
|
||||||
|
reconcile_legacy_schema: bool = True,
|
||||||
|
) -> MigrationResult:
|
||||||
|
url = database_url or settings.database_url
|
||||||
|
previous = database_revision(url)
|
||||||
|
reconciled = reconcile_legacy_create_all_schema(url) if reconcile_legacy_schema else None
|
||||||
|
command.upgrade(alembic_config(database_url=url), "head")
|
||||||
|
current = database_revision(url)
|
||||||
|
return MigrationResult(
|
||||||
|
previous_revision=previous,
|
||||||
|
reconciled_revision=reconciled,
|
||||||
|
current_revision=current,
|
||||||
|
)
|
||||||
@@ -267,6 +267,14 @@ class CampaignVersion(Base, TimestampMixin):
|
|||||||
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
|
||||||
|
# Explicit user-requested lock. This is deliberately separate from
|
||||||
|
# locked_at, which represents the reversible validation lock used by the
|
||||||
|
# build/send workflow. Temporary user locks may later receive a dedicated
|
||||||
|
# RBAC permission for unlocking; permanent locks never unlock in place.
|
||||||
|
user_lock_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||||
|
user_locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
user_locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
|
||||||
validation_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
validation_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
from app.db.bootstrap import bootstrap_dev_data, create_all_tables
|
from app.db.bootstrap import bootstrap_dev_data
|
||||||
|
from app.db.migrations import migrate_database
|
||||||
from app.db.session import SessionLocal
|
from app.db.session import SessionLocal
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
@@ -13,8 +14,13 @@ def main() -> None:
|
|||||||
parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create")
|
parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
create_all_tables()
|
migration = migrate_database()
|
||||||
print("Database tables ensured.")
|
if migration.reconciled_revision:
|
||||||
|
print(
|
||||||
|
"Reconciled legacy database marker "
|
||||||
|
f"to {migration.reconciled_revision}."
|
||||||
|
)
|
||||||
|
print(f"Database schema upgraded to {migration.current_revision}.")
|
||||||
|
|
||||||
if args.with_dev_data:
|
if args.with_dev_data:
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.mailer.messages.models import MessageAddress, MessageDraft, MessageVali
|
|||||||
from app.storage.campaign_attachments import (
|
from app.storage.campaign_attachments import (
|
||||||
annotate_built_messages_with_managed_files,
|
annotate_built_messages_with_managed_files,
|
||||||
prepared_campaign_snapshot,
|
prepared_campaign_snapshot,
|
||||||
|
public_attachment_summary_payload,
|
||||||
)
|
)
|
||||||
from app.mailer.dev.mock_mailbox import (
|
from app.mailer.dev.mock_mailbox import (
|
||||||
clear_records,
|
clear_records,
|
||||||
@@ -46,7 +47,7 @@ def _issue_payloads(message: MessageDraft) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
def _attachment_payloads(message: MessageDraft) -> list[dict[str, Any]]:
|
def _attachment_payloads(message: MessageDraft) -> list[dict[str, Any]]:
|
||||||
return [attachment.model_dump(mode="json") for attachment in message.attachments]
|
return [public_attachment_summary_payload(attachment) for attachment in message.attachments]
|
||||||
|
|
||||||
|
|
||||||
def _message_payload(message: MessageDraft) -> dict[str, Any]:
|
def _message_payload(message: MessageDraft) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import copy
|
import copy
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -29,6 +31,7 @@ from app.storage.services import record_campaign_attachment_uses_for_job
|
|||||||
from app.storage.campaign_attachments import (
|
from app.storage.campaign_attachments import (
|
||||||
annotate_built_messages_with_managed_files,
|
annotate_built_messages_with_managed_files,
|
||||||
prepared_campaign_snapshot,
|
prepared_campaign_snapshot,
|
||||||
|
public_attachment_summary_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
|
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
|
||||||
@@ -154,8 +157,15 @@ def create_campaign_version_from_json(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _version_user_lock_state(version: CampaignVersion) -> str | None:
|
||||||
|
state = getattr(version, "user_lock_state", None)
|
||||||
|
if state in {"temporary", "permanent"}:
|
||||||
|
return state
|
||||||
|
return "permanent" if version.published_at else None
|
||||||
|
|
||||||
|
|
||||||
def _version_is_user_locked(version: CampaignVersion) -> bool:
|
def _version_is_user_locked(version: CampaignVersion) -> bool:
|
||||||
return bool(version.published_at)
|
return _version_user_lock_state(version) is not None
|
||||||
|
|
||||||
|
|
||||||
def _version_is_validated_and_locked(version: CampaignVersion) -> bool:
|
def _version_is_validated_and_locked(version: CampaignVersion) -> bool:
|
||||||
@@ -164,8 +174,11 @@ def _version_is_validated_and_locked(version: CampaignVersion) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _ensure_version_validated_and_locked(version: CampaignVersion) -> None:
|
def _ensure_version_validated_and_locked(version: CampaignVersion) -> None:
|
||||||
if _version_is_user_locked(version):
|
state = _version_user_lock_state(version)
|
||||||
raise CampaignPersistenceError("User-locked audit-safe versions cannot be built, queued, dry-run or sent. Create an editable copy instead.")
|
if state == "temporary":
|
||||||
|
raise CampaignPersistenceError("This version has a temporary user lock. Unlock it before building, queueing, dry-run or sending.")
|
||||||
|
if state == "permanent":
|
||||||
|
raise CampaignPersistenceError("This version is permanently user-locked. Create an editable copy instead.")
|
||||||
if not _version_is_validated_and_locked(version):
|
if not _version_is_validated_and_locked(version):
|
||||||
raise CampaignPersistenceError("Campaign version must be validated and locked before building, queueing, dry-run or sending.")
|
raise CampaignPersistenceError("Campaign version must be validated and locked before building, queueing, dry-run or sending.")
|
||||||
|
|
||||||
@@ -190,14 +203,15 @@ 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")
|
||||||
if version.published_at 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,
|
||||||
CampaignVersionWorkflowState.COMPLETED.value,
|
CampaignVersionWorkflowState.COMPLETED.value,
|
||||||
CampaignVersionWorkflowState.CANCELLED.value,
|
CampaignVersionWorkflowState.CANCELLED.value,
|
||||||
CampaignVersionWorkflowState.ARCHIVED.value,
|
CampaignVersionWorkflowState.ARCHIVED.value,
|
||||||
}:
|
}:
|
||||||
raise CampaignPersistenceError("Audit-safe/final campaign versions cannot be validated. Create an editable copy instead.")
|
lock_label = "temporarily user-locked" if _version_user_lock_state(version) == "temporary" else "permanently locked/final"
|
||||||
|
raise CampaignPersistenceError(f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead.")
|
||||||
|
|
||||||
if check_files:
|
if check_files:
|
||||||
with prepared_campaign_snapshot(
|
with prepared_campaign_snapshot(
|
||||||
@@ -289,7 +303,7 @@ def _job_from_message(
|
|||||||
"bounce_to": [item.model_dump(mode="json") for item in message.bounce_to],
|
"bounce_to": [item.model_dump(mode="json") for item in message.bounce_to],
|
||||||
"disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to],
|
"disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to],
|
||||||
},
|
},
|
||||||
resolved_attachments=[item.model_dump(mode="json") for item in message.attachments],
|
resolved_attachments=[public_attachment_summary_payload(item) for item in message.attachments],
|
||||||
issues_snapshot=[item.model_dump(mode="json") for item in message.issues],
|
issues_snapshot=[item.model_dump(mode="json") for item in message.issues],
|
||||||
last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None,
|
last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None,
|
||||||
)
|
)
|
||||||
@@ -326,6 +340,11 @@ def build_campaign_version(
|
|||||||
result = build_campaign_messages(managed_config, campaign_file=prepared.path, output_dir=output_dir, write_eml=write_eml)
|
result = build_campaign_messages(managed_config, campaign_file=prepared.path, output_dir=output_dir, write_eml=write_eml)
|
||||||
annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path)
|
annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path)
|
||||||
report_json = result.report.model_dump(mode="json", by_alias=True)
|
report_json = result.report.model_dump(mode="json", by_alias=True)
|
||||||
|
for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False):
|
||||||
|
if isinstance(message_payload, dict):
|
||||||
|
message_payload["attachments"] = [public_attachment_summary_payload(item) for item in message.attachments]
|
||||||
|
report_json["built_at"] = datetime.now(UTC).isoformat()
|
||||||
|
report_json["build_token"] = uuid4().hex
|
||||||
report_json.update({
|
report_json.update({
|
||||||
"built_count": result.report.built_count,
|
"built_count": result.report.built_count,
|
||||||
"build_failed_count": result.report.build_failed_count,
|
"build_failed_count": result.report.build_failed_count,
|
||||||
@@ -338,6 +357,9 @@ def build_campaign_version(
|
|||||||
"queueable_count": result.report.queueable_count,
|
"queueable_count": result.report.queueable_count,
|
||||||
})
|
})
|
||||||
version.build_summary = report_json
|
version.build_summary = report_json
|
||||||
|
editor_state = copy.deepcopy(version.editor_state or {})
|
||||||
|
editor_state.pop("review_send", None)
|
||||||
|
version.editor_state = editor_state
|
||||||
|
|
||||||
# Rebuild jobs for the current version. Later, protect sent jobs from destructive rebuilds.
|
# Rebuild jobs for the current version. Later, protect sent jobs from destructive rebuilds.
|
||||||
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_not(None)).delete(synchronize_session=False)
|
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_not(None)).delete(synchronize_session=False)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import copy
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -33,6 +34,26 @@ class LockedCampaignVersionError(CampaignPersistenceError):
|
|||||||
"""Raised when a caller tries to edit an immutable campaign version."""
|
"""Raised when a caller tries to edit an immutable campaign version."""
|
||||||
|
|
||||||
|
|
||||||
|
USER_LOCK_TEMPORARY = "temporary"
|
||||||
|
USER_LOCK_PERMANENT = "permanent"
|
||||||
|
USER_LOCK_STATES = {USER_LOCK_TEMPORARY, USER_LOCK_PERMANENT}
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_version_user_lock_state(version: CampaignVersion) -> str | None:
|
||||||
|
"""Return the explicit user-lock state with backwards compatibility.
|
||||||
|
|
||||||
|
Older databases represented a permanent user lock only through
|
||||||
|
published_at. Treat those rows as permanent until the migration has
|
||||||
|
backfilled the explicit state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
state = getattr(version, "user_lock_state", None)
|
||||||
|
if state in USER_LOCK_STATES:
|
||||||
|
return state
|
||||||
|
if version.published_at:
|
||||||
|
return USER_LOCK_PERMANENT
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def minimal_campaign_json(*, external_id: str, name: str, description: str | None = None) -> dict[str, Any]:
|
def minimal_campaign_json(*, external_id: str, name: str, description: str | None = None) -> dict[str, Any]:
|
||||||
"""Return a WebUI-friendly starter campaign JSON.
|
"""Return a WebUI-friendly starter campaign JSON.
|
||||||
@@ -227,9 +248,13 @@ LOCKED_WORKFLOW_STATES = {
|
|||||||
|
|
||||||
|
|
||||||
def is_version_locked(version: CampaignVersion) -> bool:
|
def is_version_locked(version: CampaignVersion) -> bool:
|
||||||
"""Return True when a version is immutable and edits must fork."""
|
"""Return True when a version is immutable and edits must fork/unlock."""
|
||||||
|
|
||||||
return bool(version.locked_at or version.workflow_state in LOCKED_WORKFLOW_STATES)
|
return bool(
|
||||||
|
version.locked_at
|
||||||
|
or campaign_version_user_lock_state(version)
|
||||||
|
or version.workflow_state in LOCKED_WORKFLOW_STATES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _apply_campaign_metadata(campaign: Campaign, raw_json: dict[str, Any]) -> None:
|
def _apply_campaign_metadata(campaign: Campaign, raw_json: dict[str, Any]) -> None:
|
||||||
@@ -312,16 +337,24 @@ def is_version_final_locked(version: CampaignVersion) -> bool:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_user_locked_version(version: CampaignVersion) -> bool:
|
def is_temporary_user_locked_version(version: CampaignVersion) -> bool:
|
||||||
"""Return True when a user explicitly locked a version as an audit-safe snapshot."""
|
return campaign_version_user_lock_state(version) == USER_LOCK_TEMPORARY
|
||||||
|
|
||||||
return bool(version.published_at)
|
|
||||||
|
def is_permanent_user_locked_version(version: CampaignVersion) -> bool:
|
||||||
|
return campaign_version_user_lock_state(version) == USER_LOCK_PERMANENT
|
||||||
|
|
||||||
|
|
||||||
|
def is_user_locked_version(version: CampaignVersion) -> bool:
|
||||||
|
"""Return True for either reversible or permanent user-requested locks."""
|
||||||
|
|
||||||
|
return campaign_version_user_lock_state(version) is not None
|
||||||
|
|
||||||
|
|
||||||
def is_audit_safe_version(version: CampaignVersion) -> bool:
|
def is_audit_safe_version(version: CampaignVersion) -> bool:
|
||||||
"""Return True when a version is immutable and cannot be unlocked."""
|
"""Return True when a version is immutable and cannot be unlocked."""
|
||||||
|
|
||||||
return is_user_locked_version(version) or is_version_final_locked(version)
|
return is_permanent_user_locked_version(version) or is_version_final_locked(version)
|
||||||
|
|
||||||
|
|
||||||
def is_version_validated_and_locked(version: CampaignVersion) -> bool:
|
def is_version_validated_and_locked(version: CampaignVersion) -> bool:
|
||||||
@@ -349,8 +382,10 @@ def unlock_validated_campaign_version(
|
|||||||
campaign = session.get(Campaign, campaign_id)
|
campaign = session.get(Campaign, campaign_id)
|
||||||
assert campaign is not None
|
assert campaign is not None
|
||||||
|
|
||||||
if is_user_locked_version(version):
|
if is_temporary_user_locked_version(version):
|
||||||
raise LockedCampaignVersionError("This version was locked as an audit-safe snapshot and cannot be unlocked. Create an editable copy instead.")
|
raise LockedCampaignVersionError("This version has a temporary user lock. Remove that lock before unlocking validation.")
|
||||||
|
if is_permanent_user_locked_version(version):
|
||||||
|
raise LockedCampaignVersionError("This version is permanently locked and cannot be unlocked. Create an editable copy instead.")
|
||||||
if is_version_final_locked(version):
|
if is_version_final_locked(version):
|
||||||
raise LockedCampaignVersionError("This version is already queued/sent/final and cannot be unlocked. Create an editable copy instead.")
|
raise LockedCampaignVersionError("This version is already queued/sent/final and cannot be unlocked. Create an editable copy instead.")
|
||||||
|
|
||||||
@@ -370,6 +405,9 @@ def unlock_validated_campaign_version(
|
|||||||
version.locked_by_user_id = None
|
version.locked_by_user_id = None
|
||||||
version.validation_summary = None
|
version.validation_summary = None
|
||||||
version.build_summary = None
|
version.build_summary = None
|
||||||
|
editor_state = copy.deepcopy(version.editor_state or {})
|
||||||
|
editor_state.pop("review_send", None)
|
||||||
|
version.editor_state = editor_state
|
||||||
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
|
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
|
||||||
version.is_complete = False
|
version.is_complete = False
|
||||||
|
|
||||||
@@ -450,29 +488,176 @@ def update_campaign_version(
|
|||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
def publish_campaign_version(
|
def update_campaign_review_state(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
version_id: str,
|
||||||
|
inspection_complete: bool,
|
||||||
|
reviewed_message_keys: list[str],
|
||||||
|
user_id: str | None,
|
||||||
|
) -> CampaignVersion:
|
||||||
|
"""Persist review acknowledgement without mutating the locked campaign data.
|
||||||
|
|
||||||
|
Validation locks make the campaign JSON immutable, but review metadata is
|
||||||
|
operational state attached to a specific build. It is therefore stored in
|
||||||
|
editor_state and tied to the current build token so a rebuild invalidates it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
version = get_campaign_version_for_tenant(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
version_id=version_id,
|
||||||
|
)
|
||||||
|
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 {}
|
||||||
|
if not build_summary:
|
||||||
|
raise CampaignPersistenceError("Build messages before recording review state.")
|
||||||
|
build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "").strip()
|
||||||
|
if not build_token:
|
||||||
|
# Backwards-compatible upgrade for build summaries created before
|
||||||
|
# review-state tokens were introduced.
|
||||||
|
build_token = uuid4().hex
|
||||||
|
build_summary = copy.deepcopy(build_summary)
|
||||||
|
build_summary["build_token"] = build_token
|
||||||
|
version.build_summary = build_summary
|
||||||
|
|
||||||
|
editor_state = copy.deepcopy(version.editor_state or {})
|
||||||
|
editor_state["review_send"] = {
|
||||||
|
"build_token": build_token,
|
||||||
|
"inspection_complete": bool(inspection_complete),
|
||||||
|
"reviewed_message_keys": list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip())),
|
||||||
|
"updated_at": datetime.now(UTC).isoformat(),
|
||||||
|
"updated_by_user_id": user_id,
|
||||||
|
}
|
||||||
|
version.editor_state = editor_state
|
||||||
|
session.add(version)
|
||||||
|
session.commit()
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def lock_campaign_version_temporarily(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
version_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
) -> CampaignVersion:
|
||||||
|
"""Apply a reversible user-requested lock without changing workflow state."""
|
||||||
|
|
||||||
|
version = get_campaign_version_for_tenant(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
version_id=version_id,
|
||||||
|
)
|
||||||
|
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):
|
||||||
|
raise LockedCampaignVersionError("This version is already permanently locked.")
|
||||||
|
if is_temporary_user_locked_version(version):
|
||||||
|
return version
|
||||||
|
if version.locked_at:
|
||||||
|
raise LockedCampaignVersionError("This version is already temporarily locked by validation. Unlock validation before applying a user lock.")
|
||||||
|
|
||||||
|
version.user_lock_state = USER_LOCK_TEMPORARY
|
||||||
|
version.user_locked_at = datetime.now(UTC)
|
||||||
|
version.user_locked_by_user_id = user_id
|
||||||
|
session.add(version)
|
||||||
|
session.commit()
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def unlock_user_locked_campaign_version(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
version_id: str,
|
version_id: str,
|
||||||
) -> CampaignVersion:
|
) -> CampaignVersion:
|
||||||
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
"""Remove a reversible user lock without invalidating campaign data."""
|
||||||
campaign = session.get(Campaign, campaign_id)
|
|
||||||
assert campaign is not None
|
version = get_campaign_version_for_tenant(
|
||||||
now = datetime.now(UTC)
|
session,
|
||||||
version.workflow_state = CampaignVersionWorkflowState.ARCHIVED.value
|
tenant_id=tenant_id,
|
||||||
version.published_at = now
|
campaign_id=campaign_id,
|
||||||
if version.locked_at is None:
|
version_id=version_id,
|
||||||
version.locked_at = now
|
)
|
||||||
campaign.current_version_id = version.id
|
state = campaign_version_user_lock_state(version)
|
||||||
campaign.status = CampaignStatus.ARCHIVED.value
|
if state == USER_LOCK_PERMANENT:
|
||||||
|
raise LockedCampaignVersionError("Permanently locked versions cannot be unlocked. Create an editable copy instead.")
|
||||||
|
if state != USER_LOCK_TEMPORARY:
|
||||||
|
raise LockedCampaignVersionError("This version does not have a temporary user lock.")
|
||||||
|
if is_version_final_locked(version):
|
||||||
|
raise LockedCampaignVersionError("Delivery/final versions cannot be unlocked. Create an editable copy instead.")
|
||||||
|
|
||||||
|
version.user_lock_state = None
|
||||||
|
version.user_locked_at = None
|
||||||
|
version.user_locked_by_user_id = None
|
||||||
session.add(version)
|
session.add(version)
|
||||||
session.add(campaign)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def permanently_lock_campaign_version(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
version_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
) -> CampaignVersion:
|
||||||
|
"""Apply an irreversible user lock.
|
||||||
|
|
||||||
|
The version remains in its current workflow state so the campaign itself is
|
||||||
|
not silently archived. Future changes must be made in an editable copy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
version = get_campaign_version_for_tenant(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
version_id=version_id,
|
||||||
|
)
|
||||||
|
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):
|
||||||
|
return version
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
version.user_lock_state = USER_LOCK_PERMANENT
|
||||||
|
version.user_locked_at = now
|
||||||
|
version.user_locked_by_user_id = user_id
|
||||||
|
# Retain published_at as a compatibility marker for existing integrations.
|
||||||
|
version.published_at = version.published_at or now
|
||||||
|
session.add(version)
|
||||||
|
session.commit()
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def publish_campaign_version(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
version_id: str,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> CampaignVersion:
|
||||||
|
"""Backwards-compatible alias for the permanent user lock."""
|
||||||
|
|
||||||
|
return permanently_lock_campaign_version(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
version_id=version_id,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None = None) -> dict[str, Any]:
|
def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None = None) -> dict[str, Any]:
|
||||||
"""Lightweight UI-facing validation for incomplete campaign working copies.
|
"""Lightweight UI-facing validation for incomplete campaign working copies.
|
||||||
|
|
||||||
|
|||||||
@@ -131,8 +131,15 @@ QUEUEABLE_VALIDATION_STATUSES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _version_user_lock_state(version: CampaignVersion) -> str | None:
|
||||||
|
state = getattr(version, "user_lock_state", None)
|
||||||
|
if state in {"temporary", "permanent"}:
|
||||||
|
return state
|
||||||
|
return "permanent" if version.published_at else None
|
||||||
|
|
||||||
|
|
||||||
def _version_is_user_locked(version: CampaignVersion) -> bool:
|
def _version_is_user_locked(version: CampaignVersion) -> bool:
|
||||||
return bool(version.published_at)
|
return _version_user_lock_state(version) is not None
|
||||||
|
|
||||||
|
|
||||||
def _version_is_validated_and_locked(version: CampaignVersion) -> bool:
|
def _version_is_validated_and_locked(version: CampaignVersion) -> bool:
|
||||||
@@ -141,8 +148,11 @@ def _version_is_validated_and_locked(version: CampaignVersion) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _ensure_version_validated_and_locked(version: CampaignVersion) -> None:
|
def _ensure_version_validated_and_locked(version: CampaignVersion) -> None:
|
||||||
if _version_is_user_locked(version):
|
state = _version_user_lock_state(version)
|
||||||
raise QueueingError("User-locked audit-safe versions cannot be queued, dry-run or sent. Create an editable copy instead.")
|
if state == "temporary":
|
||||||
|
raise QueueingError("This version has a temporary user lock. Unlock it before queueing, dry-run or sending.")
|
||||||
|
if state == "permanent":
|
||||||
|
raise QueueingError("This version is permanently user-locked. Create an editable copy instead.")
|
||||||
if not _version_is_validated_and_locked(version):
|
if not _version_is_validated_and_locked(version):
|
||||||
raise QueueingError("Campaign version must be validated and locked before building, queueing, dry-run or sending.")
|
raise QueueingError("Campaign version must be validated and locked before building, queueing, dry-run or sending.")
|
||||||
|
|
||||||
|
|||||||
@@ -280,6 +280,42 @@ def managed_match_payloads(
|
|||||||
return payloads
|
return payloads
|
||||||
|
|
||||||
|
|
||||||
|
def public_attachment_summary_payload(value: Any) -> dict[str, Any]:
|
||||||
|
"""Return an attachment summary without temporary materialization paths.
|
||||||
|
|
||||||
|
Managed builds use isolated local directories internally. Queue, review and
|
||||||
|
audit payloads must expose the stable managed paths and immutable IDs
|
||||||
|
instead of those temporary paths. Legacy filesystem attachments remain
|
||||||
|
unchanged for backwards compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if hasattr(value, "model_dump"):
|
||||||
|
payload = value.model_dump(mode="json")
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
payload = copy.deepcopy(value)
|
||||||
|
else:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
managed_matches = payload.get("managed_matches")
|
||||||
|
if not isinstance(managed_matches, list) or not managed_matches:
|
||||||
|
return payload
|
||||||
|
|
||||||
|
logical_matches: list[str] = []
|
||||||
|
for item in managed_matches:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
display_path = str(item.get("display_path") or item.get("relative_path") or item.get("filename") or "").strip()
|
||||||
|
if display_path:
|
||||||
|
logical_matches.append(display_path)
|
||||||
|
|
||||||
|
payload["matches"] = logical_matches
|
||||||
|
# These values point into a deleted temporary materialization directory.
|
||||||
|
# The named source plus managed match metadata are the stable references.
|
||||||
|
payload["base_path"] = None
|
||||||
|
payload["directory"] = payload.get("base_path_name") or "managed"
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def annotate_built_messages_with_managed_files(
|
def annotate_built_messages_with_managed_files(
|
||||||
built_messages: list[Any],
|
built_messages: list[Any],
|
||||||
manifest: dict[str, ManagedAttachmentFile],
|
manifest: dict[str, ManagedAttachmentFile],
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ set -e
|
|||||||
ROLE="${APP_ROLE:-api}"
|
ROLE="${APP_ROLE:-api}"
|
||||||
|
|
||||||
if [ "$ROLE" = "api" ]; then
|
if [ "$ROLE" = "api" ]; then
|
||||||
|
if [ "${RUN_DB_MIGRATIONS:-true}" = "true" ]; then
|
||||||
|
python -m app.db.migrate
|
||||||
|
fi
|
||||||
|
|
||||||
exec uvicorn app.main:app \
|
exec uvicorn app.main:app \
|
||||||
--host "${APP_HOST:-0.0.0.0}" \
|
--host "${APP_HOST:-0.0.0.0}" \
|
||||||
--port "${APP_PORT:-8000}" \
|
--port "${APP_PORT:-8000}" \
|
||||||
|
|||||||
Binary file not shown.
@@ -160,6 +160,67 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(sorted(bundle.namelist()), ["inbox/report-copy.txt", "inbox/report.txt"])
|
self.assertEqual(sorted(bundle.namelist()), ["inbox/report-copy.txt", "inbox/report.txt"])
|
||||||
self.assertEqual(bundle.read("inbox/report.txt"), b"first report")
|
self.assertEqual(bundle.read("inbox/report.txt"), b"first report")
|
||||||
|
|
||||||
|
def test_temporary_and_permanent_user_lock_lifecycle(self) -> None:
|
||||||
|
headers, _ = self._login()
|
||||||
|
created = self.client.post(
|
||||||
|
"/api/v1/campaigns/new",
|
||||||
|
headers=headers,
|
||||||
|
json={"external_id": "lock-lifecycle", "name": "Lock lifecycle"},
|
||||||
|
)
|
||||||
|
self.assertEqual(created.status_code, 200, created.text)
|
||||||
|
campaign_id = created.json()["campaign"]["id"]
|
||||||
|
version_id = created.json()["version"]["id"]
|
||||||
|
|
||||||
|
temporary = self.client.post(
|
||||||
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/lock-temporarily",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(temporary.status_code, 200, temporary.text)
|
||||||
|
self.assertEqual(temporary.json()["user_lock_state"], "temporary")
|
||||||
|
self.assertTrue(temporary.json()["user_locked_at"])
|
||||||
|
|
||||||
|
blocked_update = self.client.put(
|
||||||
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
|
||||||
|
headers=headers,
|
||||||
|
json={"current_step": "fields"},
|
||||||
|
)
|
||||||
|
self.assertEqual(blocked_update.status_code, 409, blocked_update.text)
|
||||||
|
|
||||||
|
unlocked = self.client.post(
|
||||||
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/unlock-user-lock",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(unlocked.status_code, 200, unlocked.text)
|
||||||
|
self.assertIsNone(unlocked.json()["user_lock_state"])
|
||||||
|
|
||||||
|
updated = self.client.put(
|
||||||
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
|
||||||
|
headers=headers,
|
||||||
|
json={"current_step": "fields"},
|
||||||
|
)
|
||||||
|
self.assertEqual(updated.status_code, 200, updated.text)
|
||||||
|
self.assertEqual(updated.json()["current_step"], "fields")
|
||||||
|
|
||||||
|
relocked = self.client.post(
|
||||||
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/lock-temporarily",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(relocked.status_code, 200, relocked.text)
|
||||||
|
|
||||||
|
permanent = self.client.post(
|
||||||
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/lock-permanently",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(permanent.status_code, 200, permanent.text)
|
||||||
|
self.assertEqual(permanent.json()["user_lock_state"], "permanent")
|
||||||
|
self.assertTrue(permanent.json()["published_at"])
|
||||||
|
|
||||||
|
refused_unlock = self.client.post(
|
||||||
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/unlock-user-lock",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(refused_unlock.status_code, 409, refused_unlock.text)
|
||||||
|
|
||||||
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 = {
|
||||||
@@ -377,10 +438,59 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(built.status_code, 200, built.text)
|
self.assertEqual(built.status_code, 200, built.text)
|
||||||
self.assertEqual(built.json()["built_count"], 1)
|
self.assertEqual(built.json()["built_count"], 1)
|
||||||
self.assertEqual(built.json()["messages"][0]["attachment_count"], 2)
|
self.assertEqual(built.json()["messages"][0]["attachment_count"], 2)
|
||||||
|
self.assertTrue(built.json().get("built_at"))
|
||||||
|
self.assertTrue(built.json().get("build_token"))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sum(len(item["managed_matches"]) for item in built.json()["messages"][0]["attachments"]),
|
sum(len(item["managed_matches"]) for item in built.json()["messages"][0]["attachments"]),
|
||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
|
resolved_paths = {
|
||||||
|
match
|
||||||
|
for attachment in built.json()["messages"][0]["attachments"]
|
||||||
|
for match in attachment["matches"]
|
||||||
|
}
|
||||||
|
self.assertEqual(resolved_paths, {
|
||||||
|
"invoices/archive/202605-010001-report.XLSX",
|
||||||
|
"invoices/202605-010001-90100010-9601741.XLSX",
|
||||||
|
})
|
||||||
|
self.assertFalse(any("multimailer-managed-build" in value for value in resolved_paths))
|
||||||
|
|
||||||
|
jobs = self.client.get(
|
||||||
|
f"/api/v1/campaigns/{campaign_id}/jobs",
|
||||||
|
headers=headers,
|
||||||
|
params={"version_id": version_id},
|
||||||
|
)
|
||||||
|
self.assertEqual(jobs.status_code, 200, jobs.text)
|
||||||
|
self.assertEqual(len(jobs.json()["jobs"]), 1)
|
||||||
|
job = jobs.json()["jobs"][0]
|
||||||
|
self.assertEqual(job["campaign_version_id"], version_id)
|
||||||
|
self.assertEqual(job["resolved_recipients"]["to"][0]["email"], "recipient@example.org")
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
match
|
||||||
|
for attachment in job["attachments"]
|
||||||
|
for match in attachment["matches"]
|
||||||
|
},
|
||||||
|
resolved_paths,
|
||||||
|
)
|
||||||
|
|
||||||
|
review_state = self.client.post(
|
||||||
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/review-state",
|
||||||
|
headers=headers,
|
||||||
|
json={"inspection_complete": True, "reviewed_message_keys": ["recipient-1"]},
|
||||||
|
)
|
||||||
|
self.assertEqual(review_state.status_code, 200, review_state.text)
|
||||||
|
stored_review = review_state.json()["editor_state"]["review_send"]
|
||||||
|
self.assertTrue(stored_review["inspection_complete"])
|
||||||
|
self.assertEqual(stored_review["reviewed_message_keys"], ["recipient-1"])
|
||||||
|
self.assertEqual(stored_review["build_token"], built.json()["build_token"])
|
||||||
|
|
||||||
|
reloaded_version = self.client.get(
|
||||||
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(reloaded_version.status_code, 200, reloaded_version.text)
|
||||||
|
self.assertTrue(reloaded_version.json()["editor_state"]["review_send"]["inspection_complete"])
|
||||||
|
|
||||||
mocked = self.client.post(
|
mocked = self.client.post(
|
||||||
f"/api/v1/campaigns/{campaign_id}/mock-send",
|
f"/api/v1/campaigns/{campaign_id}/mock-send",
|
||||||
|
|||||||
64
server/tests/test_database_migrations.py
Normal file
64
server/tests/test_database_migrations.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.db.migrations import (
|
||||||
|
REVISION_AUTH_RBAC,
|
||||||
|
REVISION_FILE_FOLDERS,
|
||||||
|
alembic_config,
|
||||||
|
migrate_database,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseMigrationTests(unittest.TestCase):
|
||||||
|
def test_repairs_create_all_schema_drift_and_upgrades_to_head(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="msm-migration-test-") as directory:
|
||||||
|
database = Path(directory) / "legacy.db"
|
||||||
|
url = f"sqlite:///{database}"
|
||||||
|
|
||||||
|
# Reproduce the historical development database: Alembic was run
|
||||||
|
# through auth/RBAC, then create_all() created later file tables
|
||||||
|
# without advancing alembic_version and without altering the
|
||||||
|
# already-existing campaign_versions table.
|
||||||
|
command.upgrade(alembic_config(database_url=url), REVISION_AUTH_RBAC)
|
||||||
|
engine = create_engine(url)
|
||||||
|
try:
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
with engine.connect() as connection:
|
||||||
|
self.assertEqual(
|
||||||
|
MigrationContext.configure(connection).get_current_revision(),
|
||||||
|
REVISION_AUTH_RBAC,
|
||||||
|
)
|
||||||
|
self.assertIn("file_blobs", inspect(connection).get_table_names())
|
||||||
|
self.assertNotIn(
|
||||||
|
"user_lock_state",
|
||||||
|
{column["name"] for column in inspect(connection).get_columns("campaign_versions")},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
result = migrate_database(database_url=url)
|
||||||
|
self.assertEqual(result.previous_revision, REVISION_AUTH_RBAC)
|
||||||
|
self.assertEqual(result.reconciled_revision, REVISION_FILE_FOLDERS)
|
||||||
|
|
||||||
|
engine = create_engine(url)
|
||||||
|
try:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
current = MigrationContext.configure(connection).get_current_revision()
|
||||||
|
columns = {
|
||||||
|
column["name"]
|
||||||
|
for column in inspect(connection).get_columns("campaign_versions")
|
||||||
|
}
|
||||||
|
self.assertEqual(current, result.current_revision)
|
||||||
|
self.assertIn("user_lock_state", columns)
|
||||||
|
self.assertIn("user_locked_at", columns)
|
||||||
|
self.assertIn("user_locked_by_user_id", columns)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
Reference in New Issue
Block a user