Files
app-autotest/site/db/scenario_defs.py
T

265 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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-запуска сценария.
Использует pg_try_advisory_lock для АТОМАРНОЙ проверки-и-захвата.
Это устраняет race condition между lock_check и INSERT scenario_runs:
если advisory lock взят — никто другой не сможет запустить сценарий
для этого же client_id+stand, пока мы его не отпустим.
Returns:
True — можно запускать (lock взят)
False — нельзя (другой процесс держит lock) → 409 Conflict"""
conn = get_conn()
if not conn:
return True # без БД — разрешаем (fallback)
try:
cur = conn.cursor()
# hashtext даёт стабильный int из строки — одинаковый во всех сессиях
lock_key = f"scenario:{client_id}:{stand}"
cur.execute("SELECT pg_try_advisory_lock(hashtext(%s))", (lock_key,))
acquired = cur.fetchone()[0]
cur.close()
if not acquired:
put_conn(conn)
return False # lock уже взят другим процессом
# НЕ возвращаем conn в пул! Держим до unlock.
# conn будет передан в сценарий и освобождён после завершения.
return True
except Exception as e:
print(f"[DEFS] lock_check error: {e}", flush=True)
put_conn(conn)
return True # fallback
def unlock_scenario(client_id, stand, conn=None):
"""Освободить advisory lock после завершения сценария.
Вызывается из run_scenario (scenario.py) после завершения ВСЕХ шагов.
conn — то же соединение, на котором был взят lock (если есть)."""
if not conn:
conn = get_conn()
if not conn:
return
try:
lock_key = f"scenario:{client_id}:{stand}"
cur = conn.cursor()
cur.execute("SELECT pg_advisory_unlock(hashtext(%s))", (lock_key,))
cur.close()
conn.commit()
except Exception as e:
print(f"[DEFS] unlock_scenario error: {e}", flush=True)
try:
conn.rollback()
except Exception:
pass
finally:
put_conn(conn)