v1.5.4: test infrastructure + bug fixes + CI/CD + deploy gate

- 6 new test files: route discovery, strict permissions, role switch,
  CRUD operations, 500 error protection
- permission_config.py: central permission definitions for all 110+ routes
- Bug fixes: is_active checkbox in users_edit, int(id) 500 crash,
  missing validation in users_create/users_delete
- Pre-commit hooks: lint + static routing tests
- GitHub Actions CI: full pytest suite on push
- Deploy gate: pytest runs before deploy, aborts on failure
- All 3 deploy scripts: --skip-tests flag support
This commit is contained in:
2026-05-22 20:45:27 +03:00
parent 64991f0228
commit bd048ea23d
18 changed files with 1989 additions and 10 deletions
+320 -9
View File
@@ -43,7 +43,13 @@ def now_str():
return datetime.now().strftime("%d.%m.%Y %H:%M")
def ctx(request, user, **kw):
c = {"request": request, "user": user, "now": now_str()}
original_user = request.session.get("original_user")
available_roles = {"owner": ["owner", "engineer", "technician"], "engineer": ["engineer", "technician"]}
c = {"request": request, "user": user, "now": now_str(),
"role_permissions": getattr(request.state, 'role_permissions', {}),
"role_override_active": original_user is not None,
"original_role": original_user["role"] if original_user else None,
"available_roles": available_roles.get(user["role"], [user["role"]]) if user else []}
c.update(kw)
return c
@@ -65,28 +71,39 @@ async def users_list(request: Request, db: AsyncSession = Depends(get_db), user:
@router.post("/service/users/create")
async def users_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
login = form.get("login")
password = form.get("password")
if not login or not password:
return RedirectResponse(url="/service/users", status_code=302)
from app.auth import hash_password
new_user = User(
login=form.get("login"),
password_hash=hash_password(form.get("password")),
login=login,
password_hash=hash_password(password),
full_name=form.get("full_name"), role=form.get("role", "technician"),
phone=form.get("phone", ""), email=form.get("email", ""),
is_active=form.get("is_active", "1") == "1",
)
db.add(new_user); await db.flush()
await audit_log(db, user["id"], "user_create", f"Создан пользователь {form.get('login')}", request.client.host or "")
await audit_log(db, user["id"], "user_create", f"Создан пользователь {login}", request.client.host or "")
return RedirectResponse(url="/service/users", status_code=302)
@router.post("/service/users/edit")
async def users_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
u = await db.get(User, int(form.get("id")))
user_id = form.get("id")
if not user_id:
return RedirectResponse(url="/service/users", status_code=302)
try:
u = await db.get(User, int(user_id))
except (TypeError, ValueError):
return RedirectResponse(url="/service/users", status_code=302)
if not u: return RedirectResponse(url="/service/users", status_code=302)
u.full_name = form.get("full_name", u.full_name)
u.role = form.get("role", u.role)
u.phone = form.get("phone", u.phone)
u.email = form.get("email", u.email)
u.is_active = form.get("is_active") == "1"
if "is_active" in form:
u.is_active = form["is_active"] == "1"
from app.auth import hash_password
pwd = form.get("password", "")
if pwd: u.password_hash = hash_password(pwd)
@@ -96,7 +113,14 @@ async def users_edit(request: Request, db: AsyncSession = Depends(get_db), user:
@router.post("/service/users/delete")
async def users_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
u = await db.get(User, int((await request.form()).get("id")))
form = await request.form()
user_id = form.get("id")
if not user_id:
return RedirectResponse(url="/service/users", status_code=302)
try:
u = await db.get(User, int(user_id))
except (TypeError, ValueError):
return RedirectResponse(url="/service/users", status_code=302)
if u and u.role != "owner": await db.delete(u); await db.flush()
return RedirectResponse(url="/service/users", status_code=302)
@@ -466,6 +490,34 @@ async def formula_test_page(request: Request, db: AsyncSession = Depends(get_db)
return templates.TemplateResponse("pages/formula_test.html", ctx(request, user=user, formulas=formula_list, selected=selected, active_page="coefficients", title="Тест формул"))
@router.get("/service/formulas")
async def formulas_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
coeffs = (await db.execute(select(FormulaCoefficient).order_by(FormulaCoefficient.key))).scalars().all()
groups = defaultdict(list)
for c in coeffs:
prefix = c.formula_ref.split(".")[0] if c.formula_ref else "_other"
groups[prefix].append(c)
grouped = {}
for prefix in sorted(groups.keys()):
info = FORMULA_GROUPS.get(prefix, {"name": prefix, "formula": "", "result": ""})
grouped[prefix] = {"info": info, "coeffs": groups[prefix]}
formula_list = []
for prefix in sorted(groups.keys()):
info = FORMULA_GROUPS.get(prefix, {"name": prefix, "formula": "", "result": ""})
sb = FORMULA_SANDBOX.get(prefix)
formula_list.append({
"key": prefix,
"name": info["name"],
"formula": info["formula"],
"result": info["result"],
"sandbox": sb,
"coeffs": groups[prefix],
})
selected = formula_list[0]["key"] if formula_list else ""
return templates.TemplateResponse("pages/formulas.html", ctx(request, user=user, formulas=formula_list, selected=selected, groups=grouped, active_page="formulas", title="Формулы"))
@router.post("/service/coefficients/test/{formula_name}")
async def formula_test_calc(request: Request, formula_name: str, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
@@ -760,7 +812,7 @@ async def passports_list(request: Request, db: AsyncSession = Depends(get_db), u
# ===================== CHECKLIST =====================
@router.get("/service/checklist")
async def checklist_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("technician"))):
async def checklist_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("technician", "owner"))):
result = await db.execute(
select(Task).options(joinedload(Task.object)).where(
and_(Task.assigned_to == user["id"], Task.status.in_(["open","in_progress"]))
@@ -769,7 +821,7 @@ async def checklist_list(request: Request, db: AsyncSession = Depends(get_db), u
return templates.TemplateResponse("pages/checklist.html", ctx(request, user=user, tasks=result.unique().scalars().all(), active_page="checklist", title="Чек-лист"))
@router.post("/service/checklist/complete")
async def checklist_complete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("technician"))):
async def checklist_complete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("technician", "owner"))):
form = await request.form()
t = await db.get(Task, int(form.get("task_id")))
if t and t.assigned_to == user["id"]:
@@ -1078,4 +1130,263 @@ async def cases_delete(request: Request, db: AsyncSession = Depends(get_db), use
return RedirectResponse(url="/service/cases", status_code=302)
# ===================== BOT SETTINGS =====================
@router.get("/service/bot-settings")
async def bot_settings(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_settings.html", ctx(request, user=user, active_page="bot-settings", title="Настройки бота"))
@router.get("/service/bot-settings/responses")
async def bot_responses(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_responses.html", ctx(request, user=user, active_page="bot-responses", title="Шаблоны ответов бота"))
@router.get("/service/bot-settings/features")
async def bot_features(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_features.html", ctx(request, user=user, active_page="bot-features", title="Функции бота"))
@router.get("/service/bot-settings/categories")
async def bot_categories(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_categories.html", ctx(request, user=user, active_page="bot-categories", title="Категории обращений"))
@router.get("/service/bot-settings/kb")
async def bot_kb(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_kb.html", ctx(request, user=user, active_page="bot-kb", title="База знаний бота"))
@router.get("/service/bot-settings/conversations")
async def bot_conversations(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_conversations.html", ctx(request, user=user, active_page="bot-conversations", title="Диалоги бота"))
@router.get("/service/bot-settings/users")
async def bot_users(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_users.html", ctx(request, user=user, active_page="bot-users", title="Пользователи бота"))
@router.get("/service/bot-settings/test-runner")
async def bot_test_runner(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_test_runner.html", ctx(request, user=user, active_page="bot-test-runner", title="Тестовый прогон бота"))
@router.get("/service/bot-settings/analytics")
async def bot_analytics(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_analytics.html", ctx(request, user=user, active_page="bot-analytics", title="Аналитика бота"))
@router.get("/service/portal-settings")
async def portal_settings_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/portal_settings.html", ctx(request, user=user, active_page="portal-settings", title="Настройка портала"))
@router.get("/service/role-settings")
async def role_settings_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/role_settings.html", ctx(request, user=user, active_page="role-settings", title="Настройка Ролей"))
# ---- Role permissions API ----
@router.get("/service/api/role-permissions")
async def api_role_permissions_get(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
from app.models.models import RoleMenuPermission
from sqlalchemy import select
result = {"engineer": [], "technician": []}
rows = (await db.execute(select(RoleMenuPermission))).scalars().all()
for row in rows:
result.setdefault(row.role, []).append({"menu_key": row.menu_key, "visible": row.visible})
return JSONResponse(result)
@router.post("/service/api/role-permissions/save")
async def api_role_permissions_save(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
from app.models.models import RoleMenuPermission
from sqlalchemy import delete
body = await request.json()
role = body.get("role")
permissions = body.get("permissions", [])
if role not in ("engineer", "technician"):
return JSONResponse({"ok": False, "error": "Invalid role"}, status_code=400)
await db.execute(delete(RoleMenuPermission).where(RoleMenuPermission.role == role))
for p in permissions:
db.add(RoleMenuPermission(role=role, menu_key=p["menu_key"], visible=p["visible"]))
await db.commit()
return JSONResponse({"ok": True})
# ===================== ROLE SWITCH =====================
@router.post("/service/api/role/switch")
async def api_role_switch(request: Request, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
body = await request.json()
target_role = body.get("role")
current_role = user["role"]
allowed_roles = {"owner": ["owner", "engineer", "technician"], "engineer": ["engineer", "technician"]}
if current_role not in allowed_roles or target_role not in allowed_roles.get(current_role, []):
return JSONResponse({"ok": False, "error": "Forbidden"}, status_code=403)
if "original_user" not in request.session:
request.session["original_user"] = dict(user)
request.session["user"] = {**user, "role": target_role}
return JSONResponse({"ok": True, "role": target_role, "original_role": request.session["original_user"]["role"]})
@router.post("/service/api/role/reset")
async def api_role_reset(request: Request, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
if "original_user" in request.session:
request.session["user"] = request.session["original_user"]
del request.session["original_user"]
return JSONResponse({"ok": True, "role": request.session["user"]["role"]})
# ===================== BOT API (proxied from max_bot) =====================
import aiohttp
MAX_BOT_URL = "http://127.0.0.1:8002"
@router.get("/service/api/bot/settings")
async def api_bot_settings(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
async with aiohttp.ClientSession() as session:
async with session.get(f"{MAX_BOT_URL}/api/bot/settings") as resp:
return JSONResponse(await resp.json())
@router.post("/service/api/bot/settings/save")
async def api_bot_settings_save(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
async with aiohttp.ClientSession() as session:
async with session.post(f"{MAX_BOT_URL}/api/bot/settings/save", json=body) as resp:
return JSONResponse(await resp.json())
@router.get("/service/api/bot/templates")
async def api_bot_templates(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
async with aiohttp.ClientSession() as session:
async with session.get(f"{MAX_BOT_URL}/api/bot/templates") as resp:
return JSONResponse(await resp.json())
@router.post("/service/api/bot/templates/save")
async def api_bot_templates_save(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
async with aiohttp.ClientSession() as session:
async with session.post(f"{MAX_BOT_URL}/api/bot/templates/save", json=body) as resp:
return JSONResponse(await resp.json())
@router.get("/service/api/bot/features")
async def api_bot_features(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
async with aiohttp.ClientSession() as session:
async with session.get(f"{MAX_BOT_URL}/api/bot/features") as resp:
return JSONResponse(await resp.json())
@router.post("/service/api/bot/features/toggle")
async def api_bot_features_toggle(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
async with aiohttp.ClientSession() as session:
async with session.post(f"{MAX_BOT_URL}/api/bot/features/toggle", json=body) as resp:
return JSONResponse(await resp.json())
@router.get("/service/api/bot/categories")
async def api_bot_categories(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
async with aiohttp.ClientSession() as session:
async with session.get(f"{MAX_BOT_URL}/api/bot/categories") as resp:
return JSONResponse(await resp.json())
@router.post("/service/api/bot/categories/create")
async def api_bot_categories_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
async with aiohttp.ClientSession() as session:
async with session.post(f"{MAX_BOT_URL}/api/bot/categories/create", json=body) as resp:
return JSONResponse(await resp.json())
@router.post("/service/api/bot/categories/edit")
async def api_bot_categories_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
async with aiohttp.ClientSession() as session:
async with session.post(f"{MAX_BOT_URL}/api/bot/categories/edit", json=body) as resp:
return JSONResponse(await resp.json())
@router.post("/service/api/bot/categories/delete")
async def api_bot_categories_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
async with aiohttp.ClientSession() as session:
async with session.post(f"{MAX_BOT_URL}/api/bot/categories/delete", json=body) as resp:
return JSONResponse(await resp.json())
@router.get("/service/api/bot/kb")
async def api_bot_kb(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
async with aiohttp.ClientSession() as session:
async with session.get(f"{MAX_BOT_URL}/api/bot/kb") as resp:
return JSONResponse(await resp.json())
@router.post("/service/api/bot/kb/create")
async def api_bot_kb_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
async with aiohttp.ClientSession() as session:
async with session.post(f"{MAX_BOT_URL}/api/bot/kb/create", json=body) as resp:
return JSONResponse(await resp.json())
@router.post("/service/api/bot/kb/edit")
async def api_bot_kb_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
async with aiohttp.ClientSession() as session:
async with session.post(f"{MAX_BOT_URL}/api/bot/kb/edit", json=body) as resp:
return JSONResponse(await resp.json())
@router.post("/service/api/bot/kb/delete")
async def api_bot_kb_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
async with aiohttp.ClientSession() as session:
async with session.post(f"{MAX_BOT_URL}/api/bot/kb/delete", json=body) as resp:
return JSONResponse(await resp.json())
@router.get("/service/api/bot/conversations")
async def api_bot_conversations(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
async with aiohttp.ClientSession() as session:
async with session.get(f"{MAX_BOT_URL}/api/bot/conversations") as resp:
return JSONResponse(await resp.json())
@router.get("/service/api/bot/users")
async def api_bot_users(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")),
page: int = Query(1), limit: int = Query(20),
sort_by: str = Query("created_at"), sort_dir: str = Query("desc"),
consent: str = Query(None)):
params = f"?page={page}&limit={limit}&sort_by={sort_by}&sort_dir={sort_dir}"
if consent:
params += f"&consent={consent}"
async with aiohttp.ClientSession() as session:
async with session.get(f"{MAX_BOT_URL}/api/bot/users{params}") as resp:
return JSONResponse(await resp.json())
@router.get("/service/api/bot/analytics")
async def api_bot_analytics(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
async with aiohttp.ClientSession() as session:
async with session.get(f"{MAX_BOT_URL}/api/bot/analytics") as resp:
return JSONResponse(await resp.json())
@router.post("/service/api/bot/test-run")
async def api_bot_test_run(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
async with aiohttp.ClientSession() as session:
async with session.post(f"{MAX_BOT_URL}/api/bot/test-run", json=body) as resp:
return JSONResponse(await resp.json())
@router.get("/service/api/bot/broadcast/recipients")
async def api_bot_broadcast_recipients(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")),
q: str = Query(None), page: int = Query(1), limit: int = Query(200)):
params = f"?page={page}&limit={limit}"
if q:
params += f"&q={q}"
async with aiohttp.ClientSession() as session:
async with session.get(f"{MAX_BOT_URL}/api/bot/broadcast/recipients{params}") as resp:
return JSONResponse(await resp.json())
@router.get("/service/api/bot/broadcast/history")
async def api_bot_broadcast_history(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")),
page: int = Query(1), limit: int = Query(20)):
async with aiohttp.ClientSession() as session:
async with session.get(f"{MAX_BOT_URL}/api/bot/broadcast/history?page={page}&limit={limit}") as resp:
return JSONResponse(await resp.json())
@router.post("/service/api/bot/broadcast")
async def api_bot_broadcast(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
async with aiohttp.ClientSession() as session:
async with session.post(f"{MAX_BOT_URL}/api/bot/broadcast", json=body) as resp:
return JSONResponse(await resp.json())