290 lines
9.8 KiB
Python
290 lines
9.8 KiB
Python
from datetime import datetime
|
||
|
||
from sqlalchemy import event
|
||
from sqlalchemy.orm import Session
|
||
|
||
# Импортируем модели, чтобы метаданные SQLAlchemy были доступны при инициализации.
|
||
from .component import Component, Ingredient # noqa: F401
|
||
from .recipe import Recipe, UnloadingGroup, PeriodRecipe # noqa: F401
|
||
from .report import ( # noqa: F401
|
||
LoadingReport,
|
||
LoadingReportComponent,
|
||
ComponentLoadingTime,
|
||
UnloadingReport,
|
||
UnloadingReportGroup,
|
||
)
|
||
from .feed_alert import FeedAlert # noqa: F401
|
||
from .feed_quality_settings import FeedQualitySettings # noqa: F401
|
||
from .org_settings import OrgSettings # noqa: F401
|
||
from .daily_component_norm_adjustment import DailyComponentNormAdjustment # noqa: F401
|
||
from .daily_trip_skip import DailyTripSkip # noqa: F401
|
||
from .daily_ingredient_skip import DailyIngredientSkip # noqa: F401
|
||
from .daily_ingredient_replacement import DailyIngredientReplacement # noqa: F401
|
||
from .daily_unloading_group_skip import DailyUnloadingGroupSkip # noqa: F401
|
||
from .equipment import ( # noqa: F401
|
||
FeedMixer,
|
||
FeedDispenser,
|
||
FeedingLocation,
|
||
FeedingPeriod,
|
||
FeedingPoint,
|
||
Trip,
|
||
)
|
||
from .sklad import ComponentStock # noqa: F401
|
||
from .hardware_setting import HardwareSetting # noqa: F401
|
||
from .kiosk import KioskDevice, KioskPairToken # noqa: F401
|
||
from .user import WebUser # noqa: F401
|
||
from .auto_update_settings import AutoUpdateSettings # noqa: F401
|
||
from .zootech_notification import ZootechNotification # noqa: F401
|
||
from .sync import ( # noqa: F401
|
||
SyncMetadata,
|
||
SyncClient,
|
||
SyncClientDisplayName,
|
||
SyncEngineState,
|
||
SyncQueue,
|
||
SyncDelivery,
|
||
SyncConflict,
|
||
)
|
||
|
||
|
||
from .base import TimestampMixin, SoftDeleteMixin # noqa: F401
|
||
|
||
|
||
def _cascade_on_parent_soft_delete(mapper, connection, target) -> None: # type: ignore[override]
|
||
if not getattr(target, "is_deleted", False):
|
||
return
|
||
table = getattr(target, "__tablename__", "")
|
||
if not table:
|
||
return
|
||
from sqlalchemy.orm import object_session
|
||
|
||
from app.services.sync_cascade import CASCADE_CHILDREN, cascade_soft_delete
|
||
|
||
if table not in CASCADE_CHILDREN:
|
||
return
|
||
sess = object_session(target)
|
||
suppress_enqueue = bool(
|
||
sess is not None and sess.info.get(WESP_SUPPRESS_SYNC_ENQUEUE)
|
||
)
|
||
cascade_soft_delete(
|
||
table,
|
||
str(getattr(target, "id", "")),
|
||
deleted_by=str(getattr(target, "deleted_by", None) or f"cascade:{table}"),
|
||
enqueue=not suppress_enqueue,
|
||
defer_enqueue=True,
|
||
)
|
||
|
||
|
||
for _parent_table in (
|
||
"recipe",
|
||
"component",
|
||
"feed_dispenser",
|
||
"feeding_period",
|
||
"feed_mixer",
|
||
"loading_report",
|
||
"unloading_report",
|
||
):
|
||
_parent_model = {
|
||
"recipe": Recipe,
|
||
"component": Component,
|
||
"feed_dispenser": FeedDispenser,
|
||
"feeding_period": FeedingPeriod,
|
||
"feed_mixer": FeedMixer,
|
||
"loading_report": LoadingReport,
|
||
"unloading_report": UnloadingReport,
|
||
}[_parent_table]
|
||
event.listen(_parent_model, "after_update", _cascade_on_parent_soft_delete)
|
||
|
||
|
||
def _sync_record_id(target) -> str:
|
||
table = getattr(target, "__tablename__", "")
|
||
if table == "period_recipes":
|
||
return f"{target.period_id}:{target.recipe_id}"
|
||
return target.id
|
||
|
||
|
||
_SYNC_ENQUEUE_INFO_KEY = "_wesp_sync_enqueue_pending"
|
||
# session.info: применение pull на клиенте (sync_client.apply_batch) — не ставить задачи в sync_queue.
|
||
WESP_SUPPRESS_SYNC_ENQUEUE = "_wesp_suppress_sync_enqueue"
|
||
|
||
|
||
def _schedule_sync_enqueue_from_listener(target, action: str, priority: int) -> None:
|
||
"""Не вызывать enqueue_sync_queue_task из after_insert/after_update — это внутри flush."""
|
||
from sqlalchemy.orm import object_session
|
||
|
||
sess = object_session(target)
|
||
if sess is not None and sess.info.get(WESP_SUPPRESS_SYNC_ENQUEUE):
|
||
return
|
||
rid = _sync_record_id(target)
|
||
row = (target.__tablename__, str(rid), action, priority, None)
|
||
if sess is None:
|
||
from app.services.sync_manager import enqueue_sync_queue_task
|
||
|
||
tname, rec_id, act, pri, tid = row
|
||
enqueue_sync_queue_task(tname, rec_id, act, priority=pri, target_node_id=tid)
|
||
return
|
||
sess.info.setdefault(_SYNC_ENQUEUE_INFO_KEY, []).append(row)
|
||
|
||
|
||
@event.listens_for(Session, "after_flush")
|
||
def _flush_pending_sync_enqueues(session, flush_context) -> None: # type: ignore[override]
|
||
from app.services.sync_cascade import CASCADE_ENQUEUE_INFO_KEY
|
||
|
||
pending = session.info.pop(_SYNC_ENQUEUE_INFO_KEY, None)
|
||
cascade_pending = session.info.pop(CASCADE_ENQUEUE_INFO_KEY, None)
|
||
from app.services.sync_manager import enqueue_sync_queue_task
|
||
|
||
if cascade_pending:
|
||
merged_cascade: set[tuple[str, str]] = set()
|
||
for tname, rid in cascade_pending:
|
||
merged_cascade.add((tname, rid))
|
||
with session.no_autoflush:
|
||
for tname, rid in merged_cascade:
|
||
enqueue_sync_queue_task(tname, rid, "update", priority=1)
|
||
|
||
if not pending:
|
||
return
|
||
|
||
merged: dict[tuple[str, str, str, str | None], tuple] = {}
|
||
for table_name, record_id, action, priority, target_node_id in pending:
|
||
key = (table_name, record_id, action, target_node_id)
|
||
pri = int(priority)
|
||
prev = merged.get(key)
|
||
if prev is None or pri > prev[3]:
|
||
merged[key] = (table_name, record_id, action, pri, target_node_id)
|
||
|
||
with session.no_autoflush:
|
||
for tname, rec_id, act, pri, tid in merged.values():
|
||
enqueue_sync_queue_task(tname, rec_id, act, priority=pri, target_node_id=tid)
|
||
|
||
|
||
def _sync_task_after_insert(mapper, connection, target): # type: ignore[override]
|
||
"""Универсальная постановка в sync_queue после insert (паритет с legacy sync_models)."""
|
||
priority_map = {
|
||
"component": 2,
|
||
"recipe": 2,
|
||
"ingredient": 3,
|
||
"unloading_group": 4,
|
||
"loading_report": 4,
|
||
"loading_report_component": 4,
|
||
"component_loading_time": 4,
|
||
"unloading_report": 4,
|
||
"unloading_report_group": 4,
|
||
"feed_mixer": 3,
|
||
"feeding_location": 3,
|
||
"feeding_period": 3,
|
||
"feeding_point": 3,
|
||
"trip": 4,
|
||
"feed_dispenser": 3,
|
||
"period_recipes": 3,
|
||
"daily_trip_skip": 2,
|
||
"daily_ingredient_skip": 2,
|
||
"daily_unloading_group_skip": 2,
|
||
"daily_ingredient_replacement": 2,
|
||
"daily_component_norm_adjustment": 2,
|
||
}
|
||
priority = priority_map.get(target.__tablename__, 3)
|
||
_schedule_sync_enqueue_from_listener(target, "create", priority)
|
||
|
||
|
||
def _sync_task_after_update(mapper, connection, target): # type: ignore[override]
|
||
# soft_delete / каскад: update в очередь не ставим — delete или cascade_soft_delete уже поставили задачи
|
||
if getattr(target, "is_deleted", False):
|
||
return
|
||
priority_map = {
|
||
"component": 3,
|
||
"recipe": 3,
|
||
"ingredient": 4,
|
||
"unloading_group": 4,
|
||
"loading_report": 4,
|
||
"loading_report_component": 4,
|
||
"component_loading_time": 4,
|
||
"unloading_report": 4,
|
||
"unloading_report_group": 4,
|
||
"feed_mixer": 4,
|
||
"feeding_location": 4,
|
||
"feeding_period": 4,
|
||
"feeding_point": 4,
|
||
"trip": 4,
|
||
"feed_dispenser": 4,
|
||
"period_recipes": 4,
|
||
"daily_trip_skip": 3,
|
||
"daily_ingredient_skip": 3,
|
||
"daily_unloading_group_skip": 3,
|
||
"daily_ingredient_replacement": 3,
|
||
"daily_component_norm_adjustment": 3,
|
||
}
|
||
priority = priority_map.get(target.__tablename__, 4)
|
||
_schedule_sync_enqueue_from_listener(target, "update", priority)
|
||
|
||
|
||
_sync_models = (
|
||
Component,
|
||
Recipe,
|
||
Ingredient,
|
||
UnloadingGroup,
|
||
LoadingReport,
|
||
LoadingReportComponent,
|
||
ComponentLoadingTime,
|
||
UnloadingReport,
|
||
UnloadingReportGroup,
|
||
FeedMixer,
|
||
FeedingLocation,
|
||
FeedingPeriod,
|
||
FeedingPoint,
|
||
Trip,
|
||
FeedDispenser,
|
||
PeriodRecipe,
|
||
DailyTripSkip,
|
||
DailyIngredientSkip,
|
||
DailyUnloadingGroupSkip,
|
||
DailyIngredientReplacement,
|
||
DailyComponentNormAdjustment,
|
||
)
|
||
|
||
def _model_has_content_hash_column(cls) -> bool:
|
||
try:
|
||
return "content_hash" in cls.__table__.columns
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _sync_content_hash_after_insert(mapper, connection, target) -> None: # type: ignore[override]
|
||
"""После INSERT строка уже содержит значения по умолчанию из БД — хеш совпадает с get_object_data."""
|
||
from sqlalchemy import and_, update
|
||
|
||
from app.services.sync_content_hash import compute_content_hash_for_object
|
||
|
||
if not hasattr(target, "content_hash"):
|
||
return
|
||
h = compute_content_hash_for_object(target)
|
||
if not h:
|
||
return
|
||
table = mapper.local_table
|
||
pk_cols = list(table.primary_key.columns)
|
||
stmt = update(table).values(content_hash=h)
|
||
if len(pk_cols) == 1:
|
||
pk = pk_cols[0]
|
||
stmt = stmt.where(pk == getattr(target, pk.key))
|
||
else:
|
||
stmt = stmt.where(and_(*(c == getattr(target, c.key) for c in pk_cols)))
|
||
connection.execute(stmt)
|
||
target.content_hash = h
|
||
|
||
|
||
for _sm in _sync_models:
|
||
if _model_has_content_hash_column(_sm):
|
||
event.listen(_sm, "after_insert", _sync_content_hash_after_insert)
|
||
|
||
for _sm in _sync_models:
|
||
event.listen(_sm, "after_insert", _sync_task_after_insert)
|
||
event.listen(_sm, "after_update", _sync_task_after_update)
|
||
|
||
|
||
@event.listens_for(Session, "before_flush")
|
||
def _wesp_recompute_content_hashes_before_flush(session, flush_context, instances) -> None: # type: ignore[override]
|
||
from app.services.sync_content_hash import refresh_content_hash_if_applicable
|
||
|
||
for obj in list(session.dirty):
|
||
if hasattr(obj, "content_hash"):
|
||
refresh_content_hash_if_applicable(obj)
|