campaign version refinment, user locks, db repair
This commit is contained in:
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_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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user