103 lines
3.3 KiB
Python
103 lines
3.3 KiB
Python
"""
|
||
Идемпотентная схема 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)
|