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)