From ffbddfc773ef8fdc2bcf862845c5eac2e3710049 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 13 Jun 2026 19:25:23 +0200 Subject: [PATCH] campaign version refinment, user locks, db repair --- .env.example | 2 + server/alembic/env.py | 3 +- ...f6a7b8c9d0e_campaign_version_user_locks.py | 55 +++++ server/app/api/v1/campaigns.py | 163 ++++++++++++- server/app/api/v1/schemas.py | 10 + server/app/db/migrate.py | 27 +++ server/app/db/migrations.py | 163 +++++++++++++ server/app/db/models.py | 8 + server/app/mailer/commands/init_db.py | 12 +- server/app/mailer/dev/mock_campaign.py | 3 +- server/app/mailer/persistence/campaigns.py | 34 ++- server/app/mailer/persistence/versions.py | 225 ++++++++++++++++-- server/app/mailer/sending/jobs.py | 16 +- server/app/storage/campaign_attachments.py | 36 +++ server/entrypoint.sh | 4 + server/multimailer-dev.db | Bin 1032192 -> 1130496 bytes server/tests/test_api_smoke.py | 110 +++++++++ server/tests/test_database_migrations.py | 64 +++++ 18 files changed, 896 insertions(+), 39 deletions(-) create mode 100644 server/alembic/versions/5f6a7b8c9d0e_campaign_version_user_locks.py create mode 100644 server/app/db/migrate.py create mode 100644 server/app/db/migrations.py create mode 100644 server/tests/test_database_migrations.py diff --git a/.env.example b/.env.example index b265eed..c8d3f66 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,8 @@ CELERY_PREFETCH_MULTIPLIER=1 CELERY_MAX_TASKS_PER_CHILD=200 CELERY_LOGLEVEL=INFO +RUN_DB_MIGRATIONS=true + # Existing Traefik/proxy network example EXTERNAL_PROXY_NETWORK=proxy TRAEFIK_ENTRYPOINT=websecure diff --git a/server/alembic/env.py b/server/alembic/env.py index 9d1137f..af6bf9d 100644 --- a/server/alembic/env.py +++ b/server/alembic/env.py @@ -10,7 +10,8 @@ from app.db import models # noqa: F401 - ensure models are imported from app.settings import settings 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: fileConfig(config.config_file_name) diff --git a/server/alembic/versions/5f6a7b8c9d0e_campaign_version_user_locks.py b/server/alembic/versions/5f6a7b8c9d0e_campaign_version_user_locks.py new file mode 100644 index 0000000..55ec532 --- /dev/null +++ b/server/alembic/versions/5f6a7b8c9d0e_campaign_version_user_locks.py @@ -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") diff --git a/server/app/api/v1/campaigns.py b/server/app/api/v1/campaigns.py index f95028c..6749ccd 100644 --- a/server/app/api/v1/campaigns.py +++ b/server/app/api/v1/campaigns.py @@ -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 ] diff --git a/server/app/api/v1/schemas.py b/server/app/api/v1/schemas.py index 7ec3d9a..d101682 100644 --- a/server/app/api/v1/schemas.py +++ b/server/app/api/v1/schemas.py @@ -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 diff --git a/server/app/db/migrate.py b/server/app/db/migrate.py new file mode 100644 index 0000000..f836e88 --- /dev/null +++ b/server/app/db/migrate.py @@ -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() diff --git a/server/app/db/migrations.py b/server/app/db/migrations.py new file mode 100644 index 0000000..d016c46 --- /dev/null +++ b/server/app/db/migrations.py @@ -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, + ) diff --git a/server/app/db/models.py b/server/app/db/models.py index 3e4cc05..a61044d 100644 --- a/server/app/db/models.py +++ b/server/app/db/models.py @@ -267,6 +267,14 @@ class CampaignVersion(Base, TimestampMixin): 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) + # 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) build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) diff --git a/server/app/mailer/commands/init_db.py b/server/app/mailer/commands/init_db.py index ec70584..af24287 100644 --- a/server/app/mailer/commands/init_db.py +++ b/server/app/mailer/commands/init_db.py @@ -2,7 +2,8 @@ from __future__ import annotations 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.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") args = parser.parse_args() - create_all_tables() - print("Database tables ensured.") + migration = migrate_database() + 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: with SessionLocal() as session: diff --git a/server/app/mailer/dev/mock_campaign.py b/server/app/mailer/dev/mock_campaign.py index 3266747..f3a0314 100644 --- a/server/app/mailer/dev/mock_campaign.py +++ b/server/app/mailer/dev/mock_campaign.py @@ -15,6 +15,7 @@ from app.mailer.messages.models import MessageAddress, MessageDraft, MessageVali from app.storage.campaign_attachments import ( annotate_built_messages_with_managed_files, prepared_campaign_snapshot, + public_attachment_summary_payload, ) from app.mailer.dev.mock_mailbox import ( clear_records, @@ -46,7 +47,7 @@ def _issue_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]: diff --git a/server/app/mailer/persistence/campaigns.py b/server/app/mailer/persistence/campaigns.py index efc87fc..95d54ee 100644 --- a/server/app/mailer/persistence/campaigns.py +++ b/server/app/mailer/persistence/campaigns.py @@ -4,6 +4,8 @@ import json from pathlib import Path from typing import Any import copy +from datetime import UTC, datetime +from uuid import uuid4 from sqlalchemy import func 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 ( annotate_built_messages_with_managed_files, prepared_campaign_snapshot, + public_attachment_summary_payload, ) 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: - return bool(version.published_at) + return _version_user_lock_state(version) is not None 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: - if _version_is_user_locked(version): - raise CampaignPersistenceError("User-locked audit-safe versions cannot be built, queued, dry-run or sent. Create an editable copy instead.") + state = _version_user_lock_state(version) + 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): 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) if not campaign or campaign.tenant_id != tenant_id: 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.SENDING.value, CampaignVersionWorkflowState.COMPLETED.value, CampaignVersionWorkflowState.CANCELLED.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: 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], "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], 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) 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) + 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({ "built_count": result.report.built_count, "build_failed_count": result.report.build_failed_count, @@ -338,6 +357,9 @@ def build_campaign_version( "queueable_count": result.report.queueable_count, }) 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. session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_not(None)).delete(synchronize_session=False) diff --git a/server/app/mailer/persistence/versions.py b/server/app/mailer/persistence/versions.py index 0c1c315..0672ec0 100644 --- a/server/app/mailer/persistence/versions.py +++ b/server/app/mailer/persistence/versions.py @@ -4,6 +4,7 @@ import copy from datetime import UTC, datetime from pathlib import Path from typing import Any +from uuid import uuid4 from sqlalchemy import func from sqlalchemy.orm import Session @@ -33,6 +34,26 @@ class LockedCampaignVersionError(CampaignPersistenceError): """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]: """Return a WebUI-friendly starter campaign JSON. @@ -227,9 +248,13 @@ LOCKED_WORKFLOW_STATES = { 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: @@ -312,16 +337,24 @@ def is_version_final_locked(version: CampaignVersion) -> bool: } -def is_user_locked_version(version: CampaignVersion) -> bool: - """Return True when a user explicitly locked a version as an audit-safe snapshot.""" +def is_temporary_user_locked_version(version: CampaignVersion) -> bool: + 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: """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: @@ -349,8 +382,10 @@ def unlock_validated_campaign_version( campaign = session.get(Campaign, campaign_id) assert campaign is not None - if is_user_locked_version(version): - raise LockedCampaignVersionError("This version was locked as an audit-safe snapshot and cannot be unlocked. Create an editable copy instead.") + if is_temporary_user_locked_version(version): + 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): 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.validation_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.is_complete = False @@ -450,29 +488,176 @@ def update_campaign_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, *, tenant_id: str, campaign_id: str, version_id: str, ) -> CampaignVersion: - version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) - campaign = session.get(Campaign, campaign_id) - assert campaign is not None - now = datetime.now(UTC) - version.workflow_state = CampaignVersionWorkflowState.ARCHIVED.value - version.published_at = now - if version.locked_at is None: - version.locked_at = now - campaign.current_version_id = version.id - campaign.status = CampaignStatus.ARCHIVED.value + """Remove a reversible user lock without invalidating campaign data.""" + + version = get_campaign_version_for_tenant( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + version_id=version_id, + ) + state = campaign_version_user_lock_state(version) + if state == USER_LOCK_PERMANENT: + raise LockedCampaignVersionError("Permanently locked versions cannot be unlocked. Create an editable copy instead.") + 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(campaign) session.commit() 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]: """Lightweight UI-facing validation for incomplete campaign working copies. diff --git a/server/app/mailer/sending/jobs.py b/server/app/mailer/sending/jobs.py index 78fc13a..0da7458 100644 --- a/server/app/mailer/sending/jobs.py +++ b/server/app/mailer/sending/jobs.py @@ -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: - return bool(version.published_at) + return _version_user_lock_state(version) is not None 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: - if _version_is_user_locked(version): - raise QueueingError("User-locked audit-safe versions cannot be queued, dry-run or sent. Create an editable copy instead.") + state = _version_user_lock_state(version) + 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): raise QueueingError("Campaign version must be validated and locked before building, queueing, dry-run or sending.") diff --git a/server/app/storage/campaign_attachments.py b/server/app/storage/campaign_attachments.py index d8b31c0..a0fb8bb 100644 --- a/server/app/storage/campaign_attachments.py +++ b/server/app/storage/campaign_attachments.py @@ -280,6 +280,42 @@ def managed_match_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( built_messages: list[Any], manifest: dict[str, ManagedAttachmentFile], diff --git a/server/entrypoint.sh b/server/entrypoint.sh index f04a8c4..e9d274b 100644 --- a/server/entrypoint.sh +++ b/server/entrypoint.sh @@ -4,6 +4,10 @@ set -e ROLE="${APP_ROLE:-api}" if [ "$ROLE" = "api" ]; then + if [ "${RUN_DB_MIGRATIONS:-true}" = "true" ]; then + python -m app.db.migrate + fi + exec uvicorn app.main:app \ --host "${APP_HOST:-0.0.0.0}" \ --port "${APP_PORT:-8000}" \ diff --git a/server/multimailer-dev.db b/server/multimailer-dev.db index c9d8e4ae82c1f45500480933c4aacb5ba5ab07c6..0370f6357ef14ac49c75463361617978efce68ff 100644 GIT binary patch delta 23030 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 a^b5zzV&fy)Qzum5la!B(9S4ZkvHuTXsPftX delta 5069 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 diff --git a/server/tests/test_api_smoke.py b/server/tests/test_api_smoke.py index e83b65d..de63eea 100644 --- a/server/tests/test_api_smoke.py +++ b/server/tests/test_api_smoke.py @@ -160,6 +160,67 @@ class ApiSmokeTests(unittest.TestCase): self.assertEqual(sorted(bundle.namelist()), ["inbox/report-copy.txt", "inbox/report.txt"]) 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: headers, _ = self._login() campaign_json = { @@ -377,10 +438,59 @@ class ApiSmokeTests(unittest.TestCase): self.assertEqual(built.status_code, 200, built.text) self.assertEqual(built.json()["built_count"], 1) 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( sum(len(item["managed_matches"]) for item in built.json()["messages"][0]["attachments"]), 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( f"/api/v1/campaigns/{campaign_id}/mock-send", diff --git a/server/tests/test_database_migrations.py b/server/tests/test_database_migrations.py new file mode 100644 index 0000000..fe5fbd8 --- /dev/null +++ b/server/tests/test_database_migrations.py @@ -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()