@@ -0,0 +1,289 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user