""" Идемпотентная схема WESP 2.0: недостающие таблицы (ORM) + недостающие колонки. Используется: - ревизией Alembic wesp_2_0; - после каждого upgrade head в create_app — чтобы починить БД после stamp / частичного прогона. """ from __future__ import annotations import importlib.util from pathlib import Path from alembic.operations import Operations def _load_legacy_align(): root = Path(__file__).resolve().parent.parent / "migrations" / "legacy_orm_schema_align.py" spec = importlib.util.spec_from_file_location("legacy_orm_schema_align", root) if spec is None or spec.loader is None: raise RuntimeError(f"Не найден {root}") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def _table_names_for_bind(*, reports_only: bool) -> set[str]: from app import db # noqa: WPS433 import app.models # noqa: F401, WPS433 names: set[str] = set() for mapper in db.Model.registry.mappers: cls = mapper.class_ if cls.__dict__.get("__abstract__", False): continue bk = getattr(cls, "__bind_key__", None) if reports_only: if bk != "reports": continue else: if bk == "reports": continue tab = getattr(cls, "__tablename__", None) if tab: names.add(tab) return names def run_wesp20_idempotent_sync(op: Operations, kw: dict) -> None: from app import db # noqa: WPS433 import app.models # noqa: F401, WPS433 tag = kw.get("tag") reports_only = tag == "reports" bind = op.get_bind() allowed = _table_names_for_bind(reports_only=reports_only) # У Flask-SQLAlchemy отдельный MetaData на каждый bind; db.metadata — только основной. meta = db.metadatas["reports"] if reports_only else db.metadatas[None] from app.services.migration_pace import migration_pause, migration_pause_step for table in meta.sorted_tables: if table.name not in allowed: continue table.create(bind, checkfirst=True) migration_pause_step(f"table:{table.name}") _load_legacy_align().apply_legacy_orm_gaps(op, kw) migration_pause("legacy_align") if not reports_only: repair_legacy_sync_client_snapshot_cursor(op) migration_pause("sync_clients_repair") def repair_legacy_sync_client_snapshot_cursor(op: Operations) -> int: """Старые узлы: personal_snapshot_cursor=0 при завершённом enqueue — новая логика ждёт cursor>=total.""" from sqlalchemy import inspect, text from app.services.sync_manager import SNAPSHOT_MODELS bind = op.get_bind() if bind.dialect.name != "sqlite": return 0 insp = inspect(bind) if not insp.has_table("sync_clients"): return 0 total = len(SNAPSHOT_MODELS) result = bind.execute( text( """ UPDATE sync_clients SET personal_snapshot_cursor = :total WHERE personal_snapshot_completed_at IS NOT NULL AND COALESCE(personal_snapshot_cursor, 0) < :total """ ), {"total": total}, ) return int(getattr(result, "rowcount", 0) or 0)