""" CRUD для таблицы scenario_definitions — определения сценариев. Сценарии редактируются через UI (модальный редактор), хранятся в БД. Это позволяет менять сценарии БЕЗ редеплоя приложения. ОПТИМИСТИЧНАЯ БЛОКИРОВКА (version): update_definition проверяет version — если другой пользователь уже изменил сценарий, возвращает conflict=true → UI показывает "Конфликт версий". МЯГКОЕ УДАЛЕНИЕ: delete_definition ставит is_active=FALSE — не удаляет физически. Это позволяет восстановить случайно удалённый сценарий. SEED-СЦЕНАРИИ: Сценарии с client_id="" и stand="" — seed (дефолтные, из YAML). Они доступны всем пользователям но не могут быть удалены через UI. Функции: list_definitions(client_id, stand) — список активных (+seed) get_definition(def_id, cid, stand) — один (по id) create_definition(...) → {id, version} update_definition(...) → {ok, version} | {conflict: true} delete_definition(...) → bool lock_check(cid, stand) — есть ли RUNNING → False """ import json from db.pool import get_conn, put_conn def list_definitions(client_id, stand): """Список ВСЕХ активных определений: пользовательские + seed. Seed-сценарии (client_id="" AND stand="") видны всем. Пользовательские — только для текущего пользователя и стенда. Returns: list[dict] с ключами: id, name, steps, version, is_active, created_at, updated_at, client_id, stand, is_seed (bool).""" conn = get_conn() if not conn: return [] try: cur = conn.cursor() # Два условия через OR: пользовательские + seed cur.execute(""" SELECT id, name, steps, version, is_active, created_at, updated_at, client_id, stand FROM scenario_definitions WHERE is_active = TRUE AND ((client_id = %s AND stand = %s) OR (client_id = '' AND stand = '')) ORDER BY name """, (client_id, stand)) rows = cur.fetchall() cols = [d[0] for d in cur.description] result = [] for r in rows: d = dict(zip(cols, r)) # Конвертируем datetime → ISO-строка (JSON-совместимо) d["created_at"] = d["created_at"].isoformat() if d["created_at"] else None d["updated_at"] = d["updated_at"].isoformat() if d["updated_at"] else None # is_seed: client_id пустой → seed (из YAML) d["is_seed"] = (d.get("client_id") == "" and d.get("stand") == "") result.append(d) return result except Exception as e: print(f"[DEFS] list error: {e}", flush=True) return [] finally: put_conn(conn) def get_definition(def_id, client_id, stand): """Получить ОДНО определение по id. Доступ: пользовательские (client_id+stand) ИЛИ seed (пустые). Returns: dict или None если не найдено.""" conn = get_conn() if not conn: return None try: cur = conn.cursor() cur.execute(""" SELECT id, name, steps, version, is_active, created_at, updated_at, updated_by FROM scenario_definitions WHERE id = %s AND ((client_id = %s AND stand = %s) OR (client_id = '' AND stand = '')) """, (def_id, client_id, stand)) row = cur.fetchone() if not row: return None cols = [d[0] for d in cur.description] d = dict(zip(cols, row)) d["created_at"] = d["created_at"].isoformat() if d["created_at"] else None d["updated_at"] = d["updated_at"].isoformat() if d["updated_at"] else None return d except Exception as e: print(f"[DEFS] get error: {e}", flush=True) return None finally: put_conn(conn) def create_definition(client_id, stand, name, steps, updated_by=""): """Создать ИЛИ перезаписать определение (upsert). ON CONFLICT (client_id, stand, LOWER(name)): Если сценарий с таким именем уже существует → UPDATE (перезапись steps, version+1). Если нет → INSERT. Это позволяет «сохранить» без проверки существования. Returns: {id: int, version: int} или None при ошибке.""" conn = get_conn() if not conn: return None try: cur = conn.cursor() cur.execute(""" INSERT INTO scenario_definitions (client_id, stand, name, steps, updated_by) VALUES (%s, %s, %s, %s, %s) ON CONFLICT (client_id, stand, LOWER(name)) DO UPDATE SET steps = EXCLUDED.steps, version = scenario_definitions.version + 1, is_active = TRUE, updated_at = NOW(), updated_by = EXCLUDED.updated_by RETURNING id, version """, (client_id, stand, name, json.dumps(steps), updated_by)) row = cur.fetchone() conn.commit() cur.close() return {"id": row[0], "version": row[1]} except Exception as e: print(f"[DEFS] create error: {e}", flush=True) conn.rollback() return None finally: put_conn(conn) def update_definition(def_id, client_id, stand, name, steps, version, updated_by=""): """Обновить определение с ОПТИМИСТИЧНОЙ БЛОКИРОВКОЙ. WHERE version = %s — если версия изменилась (другой пользователь/вкладка), UPDATE затронет 0 строк → возвращаем {conflict: true}. Returns: {ok: True, version: int} — успех {conflict: True} — версия устарела (409 Conflict) {error: str} — ошибка БД""" conn = get_conn() if not conn: return {"error": "no db"} try: cur = conn.cursor() cur.execute(""" UPDATE scenario_definitions SET name = %s, steps = %s, version = version + 1, updated_at = NOW(), updated_by = %s WHERE id = %s AND client_id = %s AND stand = %s AND version = %s RETURNING version """, (name, json.dumps(steps), updated_by, def_id, client_id, stand, version)) row = cur.fetchone() conn.commit() cur.close() if row: return {"ok": True, "version": row[0]} # 0 строк — версия не совпала (кто-то уже изменил) return {"conflict": True} except Exception as e: print(f"[DEFS] update error: {e}", flush=True) conn.rollback() return {"error": str(e)} finally: put_conn(conn) def delete_definition(def_id, client_id, stand): """Мягкое удаление: is_active = FALSE. Только для пользовательских сценариев (client_id не пустой). Seed-сценарии не могут быть удалены через этот метод. Returns: bool — True если удалён, False если не найден.""" conn = get_conn() if not conn: return False try: cur = conn.cursor() cur.execute(""" UPDATE scenario_definitions SET is_active = FALSE, updated_at = NOW() WHERE id = %s AND client_id = %s AND stand = %s """, (def_id, client_id, stand)) ok = cur.rowcount > 0 conn.commit() cur.close() return ok except Exception as e: print(f"[DEFS] delete error: {e}", flush=True) conn.rollback() return False finally: put_conn(conn) def lock_check(client_id, stand): """Проверить что нет активного RUNNING-запуска сценария. Используется перед запуском нового сценария — предотвращает одновременный запуск двух сценариев одним пользователем. Returns: True — можно запускать (нет RUNNING) False — нельзя (уже есть RUNNING) → 409 Conflict""" conn = get_conn() if not conn: return True # без БД — разрешаем (fallback) try: cur = conn.cursor() cur.execute(""" SELECT id FROM scenario_runs WHERE client_id = %s AND stand = %s AND status = 'RUNNING' LIMIT 1 """, (client_id, stand)) row = cur.fetchone() cur.close() return row is None # None = нет RUNNING = можно запускать except Exception as e: print(f"[DEFS] lock_check error: {e}", flush=True) return True finally: put_conn(conn)