238 lines
9.5 KiB
Python
238 lines
9.5 KiB
Python
"""
|
||
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-запуска сценария.
|
||
|
||
Атомарность гарантируется partial unique index idx_one_running
|
||
на уровне БД (см. init_db.py). Этот метод — быстрая предпроверка
|
||
для красивого 409 до попытки INSERT.
|
||
|
||
Returns:
|
||
True — можно запускать (нет RUNNING в БД)
|
||
False — нельзя (уже есть RUNNING) → 409 Conflict"""
|
||
conn = get_conn()
|
||
if not conn:
|
||
return False # без БД — не разрешаем (partial unique index недоступен)
|
||
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)
|
||
# При ошибке БД — НЕ разрешаем (безопасный fallback)
|
||
return False
|
||
finally:
|
||
put_conn(conn)
|