1437 lines
77 KiB
Python
1437 lines
77 KiB
Python
import math
|
||
from datetime import datetime
|
||
from collections import defaultdict
|
||
from fastapi import APIRouter, Request, Depends, Form, Query
|
||
from fastapi.responses import RedirectResponse, JSONResponse, HTMLResponse
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select, func, delete, or_, and_, Integer, text
|
||
from sqlalchemy.orm import selectinload, joinedload
|
||
import httpx
|
||
from app.database import get_db
|
||
from app.dependencies import get_current_user, require_role
|
||
from app.auth import audit_log
|
||
from app.models.models import (
|
||
User, Customer, Object, ObjectAssignment, SLAContract,
|
||
Task, Incident, Report, SHSRecord, EngineerKPI,
|
||
FormulaCoefficient, BlogPost, Case, QuestionnaireItem,
|
||
QuestionnaireSession, QuestionnaireAnswer, ObjectPassport,
|
||
Idea, QuickMenuItem,
|
||
)
|
||
from app.calculations import engine as calc_engine
|
||
|
||
FORMULA_GROUPS = {
|
||
"risk_score": {"name": "Оценка рисков", "formula": "Риск = no_archive×25 + no_power_backup×20 + no_regulations×15 + system_failures×20 + no_documentation×10", "result": "0–100 баллов"},
|
||
"risk_multiplier": {"name": "Множитель риска", "formula": "Множитель = f(уровень риска): низкий→1.0, средний→1.3, высокий→1.6, критический→2.0", "result": "1.0–2.0 ×"},
|
||
"complexity": {"name": "Сложность объекта", "formula": "Сложность = video(based on cameras) + access(based on APs) + fire(type) + IT(bool)", "result": "Простой(<30) / Средний(30–70) / Сложный(70–120) / Enterprise(>120)"},
|
||
"infra": {"name": "Нагрузка на инфраструктуру", "formula": "Нагрузка = server + network + power (по состояниям)", "result": "0–50 баллов"},
|
||
"history": {"name": "История обслуживания", "formula": "Присваивается вес: none→max, sla→min", "result": "Зависит от типа"},
|
||
"object_index": {"name": "Индекс объекта", "formula": "I = risk×0.4 + complexity×0.3 + infra×0.2 + history×0.1", "result": "0–100 → Класс A/B/C/D"},
|
||
"engineer": {"name": "Оценка инженера", "formula": "ES = SLA×0.25 + отклик×0.20 + решение×0.20 + диагноз×0.15 + переоткрытия×0.10 + риски×0.10 → grade", "result": "0–100 → Junior/Middle/Strong/Senior"},
|
||
"ecs": {"name": "Контроль инженера (ECS)", "formula": "ECS = SLA×0.30 + задачи×0.25 + инциденты×0.20 + команда×0.15 + координация×0.10", "result": "0–100"},
|
||
"shs": {"name": "SHS (Service Health Score)", "formula": "SHS = SLA×0.22 + выручка×0.18 + удержание×0.18 + инженеры×0.15 + инциденты×0.12 + продажи×0.10 + эффективность×0.05", "result": "0–100 → Crisis/Risk/Stable/Growth"},
|
||
"ceo_shs": {"name": "CEO SHS", "formula": "CEO SHS = MRR×0.25 + SLA×0.20 + удержание×0.20 + продукт-ть×0.15 + конверсия×0.10 + инциденты×0.10", "result": "0–100"},
|
||
"ssi": {"name": "SLA Stability Index", "formula": "SSI = SLA_compliance × (1 - Breach_Severity)", "result": "0–100"},
|
||
"isi": {"name": "Incident Stability Index", "formula": "ISI = max(0, min(100, 100 - weighted_rate×20))", "result": "0–100"},
|
||
"bonus": {"name": "Бонус инженера", "formula": "% премии = f(оценка): ≥90→20%, 80–89→10%, <80→0%", "result": "0%–20%"},
|
||
"retention": {"name": "Удержание клиентов", "formula": "Удержание = base - lost_key×10% (per key client)", "result": "0–100%"},
|
||
"rules": {"name": "Правила автоматизации", "formula": "Пороги: SHS<70→Crisis, SLA<90%→Warning, Retention<90%→Critical", "result": "Триггеры алертов"},
|
||
}
|
||
|
||
router = APIRouter()
|
||
from app.templating import templates
|
||
|
||
def now_str():
|
||
return datetime.now().strftime("%d.%m.%Y %H:%M")
|
||
|
||
def ctx(request, user, **kw):
|
||
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 [],
|
||
"page_name": kw.get("active_page", "")}
|
||
c.update(kw)
|
||
return c
|
||
|
||
|
||
async def load_quick_menu(db: AsyncSession, role: str) -> list:
|
||
result = await db.execute(
|
||
select(QuickMenuItem).where(QuickMenuItem.role == role).order_by(QuickMenuItem.sort_order, QuickMenuItem.id)
|
||
)
|
||
return result.scalars().all()
|
||
|
||
|
||
# ===================== USERS =====================
|
||
|
||
@router.get("/service/users")
|
||
async def users_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
result = await db.execute(select(User).order_by(User.role, User.full_name))
|
||
return templates.TemplateResponse("pages/users.html", ctx(request, user=user, users=result.scalars().all(), active_page="users", title="Сотрудники"))
|
||
|
||
@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
|
||
max_id_val = form.get("max_user_id")
|
||
new_user = User(
|
||
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", ""),
|
||
max_user_id=int(max_id_val) if max_id_val and max_id_val.strip() else None,
|
||
is_active=form.get("is_active", "1") == "1",
|
||
)
|
||
db.add(new_user); await db.flush()
|
||
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()
|
||
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)
|
||
max_id_val = form.get("max_user_id")
|
||
u.max_user_id = int(max_id_val) if max_id_val and max_id_val.strip() else None
|
||
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)
|
||
await db.flush()
|
||
await audit_log(db, user["id"], "user_edit", f"Изменён пользователь {u.login}", request.client.host or "")
|
||
return RedirectResponse(url="/service/users", status_code=302)
|
||
|
||
@router.post("/service/users/delete")
|
||
async def users_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
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)
|
||
|
||
|
||
# ===================== CUSTOMERS =====================
|
||
|
||
@router.get("/service/customers")
|
||
async def customers_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer")), search: str = Query(""), error: str = Query("")):
|
||
sql = select(Customer)
|
||
if search: sql = sql.where(or_(Customer.name.ilike(f"%{search}%"), Customer.inn.ilike(f"%{search}%")))
|
||
result = await db.execute(sql.order_by(Customer.name))
|
||
customers = result.scalars().all()
|
||
data = []
|
||
for c in customers:
|
||
oc = (await db.execute(select(func.count(Object.id)).where(Object.customer_id == c.id))).scalar() or 0
|
||
sc = (await db.execute(select(func.count(SLAContract.id)).where(and_(SLAContract.customer_id == c.id, SLAContract.status == "active")))).scalar() or 0
|
||
data.append({"c": c, "objects_count": oc, "active_contracts": sc})
|
||
err_msg = {"inn_invalid": "ИНН должен содержать 10 или 12 цифр"}.get(error, "")
|
||
return templates.TemplateResponse("pages/customers.html", ctx(request, user=user, customers=data, search=search, active_page="customers", title="Клиенты", error=err_msg))
|
||
|
||
@router.post("/service/customers/create")
|
||
async def customers_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
|
||
form = await request.form()
|
||
inn = form.get("inn", "").strip()
|
||
if not inn or not inn.isdigit() or len(inn) not in (10, 12):
|
||
return RedirectResponse(url="/service/customers?error=inn_invalid", status_code=302)
|
||
c = Customer(name=form.get("name"), inn=inn, kpp=form.get("kpp",""), legal_address=form.get("legal_address",""),
|
||
contact_person=form.get("contact_person",""), contact_phone=form.get("contact_phone",""), contact_email=form.get("contact_email",""),
|
||
status=form.get("status","active"), notes=form.get("notes",""), created_by=user["id"])
|
||
db.add(c); await db.flush()
|
||
return RedirectResponse(url="/service/customers", status_code=302)
|
||
|
||
@router.post("/service/customers/edit")
|
||
async def customers_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
|
||
form = await request.form()
|
||
c = await db.get(Customer, int(form.get("id")))
|
||
if not c: return RedirectResponse(url="/service/customers", status_code=302)
|
||
for f in ("name","inn","kpp","legal_address","contact_person","contact_phone","contact_email","status","notes"):
|
||
if form.get(f) is not None: setattr(c, f, form.get(f))
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/customers", status_code=302)
|
||
|
||
@router.post("/service/customers/delete")
|
||
async def customers_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
c = await db.get(Customer, int((await request.form()).get("id")))
|
||
if c: await db.delete(c); await db.flush()
|
||
return RedirectResponse(url="/service/customers", status_code=302)
|
||
|
||
|
||
# ===================== OBJECTS =====================
|
||
|
||
@router.get("/service/objects")
|
||
async def objects_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer")), search: str = Query(""), customer_id: int = Query(None)):
|
||
sql = select(Object).options(joinedload(Object.customer))
|
||
if search: sql = sql.where(or_(Object.name.ilike(f"%{search}%"), Object.address.ilike(f"%{search}%")))
|
||
if customer_id: sql = sql.where(Object.customer_id == customer_id)
|
||
result = await db.execute(sql.order_by(Object.name))
|
||
objects = result.unique().scalars().all()
|
||
cust_res = await db.execute(select(Customer).where(Customer.status == "active").order_by(Customer.name))
|
||
customers = cust_res.scalars().all()
|
||
return templates.TemplateResponse("pages/objects.html", ctx(request, user=user, objects=objects, customers_list=customers, search=search, customer_id=customer_id, active_page="objects", title="Объекты"))
|
||
|
||
@router.post("/service/objects/create")
|
||
async def objects_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
|
||
form = await request.form()
|
||
o = Object(name=form.get("name"), customer_id=int(form.get("customer_id")) if form.get("customer_id") else None,
|
||
object_type=form.get("object_type",""), address=form.get("address",""), contact_person=form.get("contact_person",""),
|
||
contact_phone=form.get("contact_phone",""), status=form.get("status","active"), notes=form.get("notes",""), created_by=user["id"])
|
||
db.add(o); await db.flush()
|
||
return RedirectResponse(url="/service/objects", status_code=302)
|
||
|
||
@router.post("/service/objects/edit")
|
||
async def objects_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
|
||
form = await request.form()
|
||
try:
|
||
oid = int(form.get("id"))
|
||
except (ValueError, TypeError):
|
||
return RedirectResponse(url="/service/objects", status_code=302)
|
||
o = await db.get(Object, oid)
|
||
if not o: return RedirectResponse(url="/service/objects", status_code=302)
|
||
for f in ("name","customer_id","object_type","address","contact_person","contact_phone","status","notes"):
|
||
val = form.get(f)
|
||
if val is not None:
|
||
if f == "customer_id":
|
||
try:
|
||
val = int(val) if val else None
|
||
except (ValueError, TypeError):
|
||
val = None
|
||
setattr(o, f, val)
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/objects", status_code=302)
|
||
|
||
@router.post("/service/objects/delete")
|
||
async def objects_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
o = await db.get(Object, int((await request.form()).get("id")))
|
||
if o: await db.delete(o); await db.flush()
|
||
return RedirectResponse(url="/service/objects", status_code=302)
|
||
|
||
|
||
# ===================== ASSIGNMENTS =====================
|
||
|
||
@router.get("/service/assignments")
|
||
async def assignments_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
objects = (await db.execute(select(Object).where(Object.status == "active").order_by(Object.name))).scalars().all()
|
||
users = (await db.execute(select(User).where(and_(User.is_active, User.role.in_(["engineer","technician"]))).order_by(User.full_name))).scalars().all()
|
||
result = await db.execute(
|
||
select(ObjectAssignment).options(
|
||
joinedload(ObjectAssignment.object),
|
||
joinedload(ObjectAssignment.user)
|
||
).where(ObjectAssignment.unassigned_at.is_(None)).order_by(ObjectAssignment.assigned_at.desc())
|
||
)
|
||
assignments = result.unique().scalars().all()
|
||
return templates.TemplateResponse("pages/assignments.html", ctx(request, user=user, objects=objects, users=users, assignments=assignments, active_page="assignments", title="Назначение сотрудников"))
|
||
|
||
@router.post("/service/assignments/create")
|
||
async def assignments_create(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("user_id")))
|
||
a = ObjectAssignment(object_id=int(form.get("object_id")), user_id=int(form.get("user_id")), role=u.role if u else "technician")
|
||
db.add(a); await db.flush()
|
||
return RedirectResponse(url="/service/assignments", status_code=302)
|
||
|
||
@router.post("/service/assignments/unassign")
|
||
async def assignments_unassign(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
a = await db.get(ObjectAssignment, int((await request.form()).get("id")))
|
||
if a: a.unassigned_at = datetime.now(); await db.flush()
|
||
return RedirectResponse(url="/service/assignments", status_code=302)
|
||
|
||
|
||
# ===================== SLA =====================
|
||
|
||
@router.get("/service/sla")
|
||
async def sla_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer")), object_id: int = Query(None)):
|
||
sql = select(SLAContract).options(joinedload(SLAContract.object), joinedload(SLAContract.customer))
|
||
if object_id: sql = sql.where(SLAContract.object_id == object_id)
|
||
result = await db.execute(sql.order_by(SLAContract.created_at.desc()))
|
||
contracts = result.unique().scalars().all()
|
||
objects = (await db.execute(select(Object).where(Object.status.in_(["active","audit"])).order_by(Object.name))).scalars().all()
|
||
return templates.TemplateResponse("pages/sla.html", ctx(request, user=user, contracts=contracts, objects=objects, sel_object_id=object_id, active_page="sla", title="SLA контракты"))
|
||
|
||
@router.post("/service/sla/create")
|
||
async def sla_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
|
||
form = await request.form()
|
||
try:
|
||
oid = int(form.get("object_id")) if form.get("object_id") else None
|
||
except (ValueError, TypeError):
|
||
oid = None
|
||
if not oid:
|
||
return RedirectResponse(url="/service/sla", status_code=302)
|
||
try:
|
||
price = float(form.get("sla_price_monthly", 0)) if form.get("sla_price_monthly") else 0
|
||
except (ValueError, TypeError):
|
||
price = 0
|
||
try:
|
||
start = datetime.strptime(form.get("start_date"), "%Y-%m-%d").date() if form.get("start_date") else None
|
||
except (ValueError, TypeError):
|
||
start = None
|
||
try:
|
||
end = datetime.strptime(form.get("end_date"), "%Y-%m-%d").date() if form.get("end_date") else None
|
||
except (ValueError, TypeError):
|
||
end = None
|
||
sc = SLAContract(object_id=oid, service_level=form.get("service_level", "start"),
|
||
sla_price_monthly=price, start_date=start, end_date=end, status=form.get("status", "negotiation"))
|
||
db.add(sc); await db.flush()
|
||
return RedirectResponse(url="/service/sla", status_code=302)
|
||
|
||
@router.post("/service/sla/edit")
|
||
async def sla_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
|
||
form = await request.form()
|
||
try:
|
||
sc_id = int(form.get("id"))
|
||
except (ValueError, TypeError):
|
||
return RedirectResponse(url="/service/sla", status_code=302)
|
||
sc = await db.get(SLAContract, sc_id)
|
||
if not sc: return RedirectResponse(url="/service/sla", status_code=302)
|
||
for f in ("object_id","service_level","sla_price_monthly","start_date","end_date","status"):
|
||
val = form.get(f)
|
||
if val is not None:
|
||
if f == "object_id":
|
||
try:
|
||
val = int(val) if val else None
|
||
except (ValueError, TypeError):
|
||
val = None
|
||
elif f == "sla_price_monthly":
|
||
try:
|
||
val = float(val) if val else 0
|
||
except (ValueError, TypeError):
|
||
val = 0
|
||
elif f in ("start_date","end_date"):
|
||
try:
|
||
val = datetime.strptime(val,"%Y-%m-%d").date() if val else None
|
||
except (ValueError, TypeError):
|
||
val = None
|
||
setattr(sc, f, val)
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/sla", status_code=302)
|
||
|
||
|
||
# ===================== CEO Dashboard =====================
|
||
|
||
@router.get("/service/ceo")
|
||
async def ceo_dashboard(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
shs_history = (await db.execute(select(SHSRecord).order_by(SHSRecord.id.desc()).limit(12))).scalars().all()
|
||
latest_shs = shs_history[0] if shs_history else None
|
||
mrr = float((await db.execute(select(func.sum(SLAContract.sla_price_monthly)).where(SLAContract.status == "active"))).scalar() or 0)
|
||
active_objects = (await db.execute(select(func.count(Object.id)).where(Object.status == "active"))).scalar() or 0
|
||
active_contracts = (await db.execute(select(func.count(SLAContract.id)).where(SLAContract.status == "active"))).scalar() or 0
|
||
active_engineers = (await db.execute(select(func.count(User.id)).where(and_(User.is_active, User.role.in_(["engineer","technician"]))))).scalar() or 0
|
||
compl_res = await db.execute(select(func.count(Task.id), func.sum(Task.sla_compliant.cast(Integer))).where(Task.closed_at != None))
|
||
total_c, compliant_c = compl_res.first() or (0,0)
|
||
sla_comp = round((compliant_c / total_c) * 100, 1) if total_c > 0 else 100
|
||
incidents_30 = (await db.execute(select(func.count(Incident.id)).where(Incident.created_at > datetime.now().replace(day=1)))).scalar() or 0
|
||
kpi_data = (await db.execute(
|
||
select(EngineerKPI, User.full_name).join(User, EngineerKPI.engineer_id == User.id).order_by(EngineerKPI.period_end.desc()).limit(10)
|
||
)).all()
|
||
avg_es = (await db.execute(select(func.avg(EngineerKPI.engineer_score)).where(EngineerKPI.period_end > datetime.now().replace(day=1)))).scalar() or 0
|
||
return templates.TemplateResponse("pages/ceo.html", ctx(request, user=user, shs_history=shs_history, latest_shs=latest_shs, mrr=mrr,
|
||
active_objects=active_objects, active_contracts=active_contracts, active_engineers=active_engineers,
|
||
sla_compliance=sla_comp, incidents_30=incidents_30, kpi_data=kpi_data, avg_es=round(float(avg_es),2),
|
||
active_page="ceo", title="CEO дашборд"))
|
||
|
||
|
||
# ===================== COEFFICIENTS =====================
|
||
|
||
@router.get("/service/coefficients")
|
||
async def coefficients_list(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]}
|
||
return templates.TemplateResponse("pages/coefficients.html", ctx(request, user=user, groups=grouped, active_page="coefficients", title="Коэффициенты"))
|
||
|
||
@router.post("/service/coefficients/edit")
|
||
async def coefficients_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
form = await request.form()
|
||
c = await db.get(FormulaCoefficient, int(form.get("id")))
|
||
if c:
|
||
try: c.value = float(form.get("value"))
|
||
except: c.value = form.get("value")
|
||
c.updated_by = user["id"]; await db.flush()
|
||
return RedirectResponse(url="/service/coefficients", status_code=302)
|
||
|
||
@router.post("/service/coefficients/toggle")
|
||
async def coefficients_toggle(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
c = await db.get(FormulaCoefficient, int((await request.form()).get("id")))
|
||
if c: c.is_active = not c.is_active; c.updated_by = user["id"]; await db.flush()
|
||
return RedirectResponse(url="/service/coefficients", status_code=302)
|
||
|
||
@router.post("/service/coefficients/create")
|
||
async def coefficients_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
form = await request.form()
|
||
db.add(FormulaCoefficient(key=form.get("key"), value=float(form.get("value")), description=form.get("description",""),
|
||
formula_ref=form.get("formula_ref",""), updated_by=user["id"]))
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/coefficients", status_code=302)
|
||
|
||
@router.post("/service/coefficients/delete")
|
||
async def coefficients_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
c = await db.get(FormulaCoefficient, int((await request.form()).get("id")))
|
||
if c: await db.delete(c); await db.flush()
|
||
return RedirectResponse(url="/service/coefficients", status_code=302)
|
||
|
||
|
||
# ===================== FORMULA TEST =====================
|
||
|
||
FORMULA_SANDBOX = {
|
||
"risk_score": {
|
||
"name": "Оценка рисков",
|
||
"inputs": [
|
||
{"key": "no_archive", "label": "Нет архивации", "type": "checkbox"},
|
||
{"key": "no_power_backup", "label": "Нет резервного питания", "type": "checkbox"},
|
||
{"key": "no_regulations", "label": "Нет регламентов", "type": "checkbox"},
|
||
{"key": "system_failures", "label": "Отказы системы", "type": "checkbox"},
|
||
{"key": "no_documentation", "label": "Нет документации", "type": "checkbox"},
|
||
],
|
||
},
|
||
"complexity": {
|
||
"name": "Сложность объекта",
|
||
"inputs": [
|
||
{"key": "cameras", "label": "Кол-во камер", "type": "number", "default": 20},
|
||
{"key": "access_points", "label": "Кол-во СКУД точек", "type": "number", "default": 5},
|
||
{"key": "fire_type", "label": "Тип пожарной системы", "type": "select", "options": {"none": "Нет", "simple": "Простая", "medium": "Средняя", "complex": "Комплексная"}},
|
||
{"key": "has_it", "label": "Есть IT-инфраструктура", "type": "checkbox"},
|
||
],
|
||
},
|
||
"object_index": {
|
||
"name": "Индекс объекта",
|
||
"inputs": [
|
||
{"key": "risk_score", "label": "Риск (0–100)", "type": "number", "default": 50},
|
||
{"key": "complexity_score", "label": "Сложность (0–150)", "type": "number", "default": 50},
|
||
{"key": "infra_score", "label": "Инфраструктура (0–50)", "type": "number", "default": 10},
|
||
{"key": "history_score", "label": "История (0–30)", "type": "number", "default": 15},
|
||
],
|
||
},
|
||
"engineer": {
|
||
"name": "Оценка инженера (Engineer Score)",
|
||
"inputs": [
|
||
{"key": "sla_compliance", "label": "SLA Compliance", "type": "number", "default": 90},
|
||
{"key": "response_time_score", "label": "Response Time Score", "type": "number", "default": 80},
|
||
{"key": "resolution_time_score", "label": "Resolution Time Score", "type": "number", "default": 80},
|
||
{"key": "diagnosis_accuracy", "label": "Diagnosis Accuracy", "type": "number", "default": 85},
|
||
{"key": "reopen_rate_score", "label": "Reopen Rate Score", "type": "number", "default": 90},
|
||
{"key": "risk_coverage_score", "label": "Risk Coverage Score", "type": "number", "default": 75},
|
||
],
|
||
},
|
||
"ecs": {
|
||
"name": "Контроль инженера (ECS)",
|
||
"inputs": [
|
||
{"key": "sla_control", "label": "SLA Control", "type": "number", "default": 85},
|
||
{"key": "task_dist", "label": "Task Distribution", "type": "number", "default": 80},
|
||
{"key": "incident_red", "label": "Incident Reduction", "type": "number", "default": 70},
|
||
{"key": "team_perf", "label": "Team Performance", "type": "number", "default": 75},
|
||
{"key": "response_coord", "label": "Response Coordination", "type": "number", "default": 80},
|
||
],
|
||
},
|
||
"shs": {
|
||
"name": "Service Health Score (SHS)",
|
||
"inputs": [
|
||
{"key": "sla_stability", "label": "SLA Stability", "type": "number", "default": 85},
|
||
{"key": "revenue_stability", "label": "Revenue Stability", "type": "number", "default": 80},
|
||
{"key": "retention", "label": "Retention", "type": "number", "default": 90},
|
||
{"key": "engineer_performance", "label": "Engineer Performance", "type": "number", "default": 75},
|
||
{"key": "incident_stability", "label": "Incident Stability", "type": "number", "default": 80},
|
||
{"key": "sales_flow", "label": "Sales Flow", "type": "number", "default": 70},
|
||
{"key": "operational_efficiency", "label": "Operational Efficiency", "type": "number", "default": 75},
|
||
],
|
||
},
|
||
"ceo_shs": {
|
||
"name": "CEO SHS",
|
||
"inputs": [
|
||
{"key": "mrr_growth", "label": "MRR Growth", "type": "number", "default": 80},
|
||
{"key": "sla_compliance", "label": "SLA Compliance", "type": "number", "default": 90},
|
||
{"key": "retention", "label": "Retention", "type": "number", "default": 85},
|
||
{"key": "productivity", "label": "Productivity", "type": "number", "default": 75},
|
||
{"key": "conversion", "label": "Conversion", "type": "number", "default": 70},
|
||
{"key": "incident_stability", "label": "Incident Stability", "type": "number", "default": 80},
|
||
],
|
||
},
|
||
}
|
||
|
||
|
||
@router.get("/service/coefficients/test")
|
||
async def formula_test_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")), group: str = Query("")):
|
||
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)
|
||
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 = group if group else (formula_list[0]["key"] if formula_list else "")
|
||
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()
|
||
try:
|
||
if formula_name == "risk_score":
|
||
result = await calc_engine.calc_risk_score(
|
||
db,
|
||
no_archive=form.get("no_archive") == "1",
|
||
no_power_backup=form.get("no_power_backup") == "1",
|
||
no_regulations=form.get("no_regulations") == "1",
|
||
system_failures=form.get("system_failures") == "1",
|
||
no_documentation=form.get("no_documentation") == "1",
|
||
)
|
||
label = await calc_engine.risk_label(db, result)
|
||
return JSONResponse({"result": f"{result:.1f} баллов — {label}"})
|
||
|
||
elif formula_name == "complexity":
|
||
result = await calc_engine.calc_complexity(
|
||
db,
|
||
cameras=int(form.get("cameras", 0)),
|
||
access_points=int(form.get("access_points", 0)),
|
||
fire_type=form.get("fire_type", "none"),
|
||
has_it=form.get("has_it") == "1",
|
||
)
|
||
label = calc_engine.complexity_label(result)
|
||
return JSONResponse({"result": f"{result:.1f} баллов — {label}"})
|
||
|
||
elif formula_name == "object_index":
|
||
# These are pre-calculated scores, not the same as coefficient-based.
|
||
# We use the weighted formula directly.
|
||
risk = float(form.get("risk_score", 50))
|
||
complexity = float(form.get("complexity_score", 50))
|
||
infra = float(form.get("infra_score", 10))
|
||
history = float(form.get("history_score", 15))
|
||
result = await calc_engine.calc_object_index(db, risk, complexity, infra, history)
|
||
cls = calc_engine.object_class(result)
|
||
label = calc_engine.object_class_label(result)
|
||
return JSONResponse({"result": f"Индекс: {result} — Класс {cls} ({label})"})
|
||
|
||
elif formula_name == "engineer":
|
||
result = await calc_engine.calc_engineer_score(
|
||
db,
|
||
sla_compliance=float(form.get("sla_compliance", 90)),
|
||
response_time_score=float(form.get("response_time_score", 80)),
|
||
resolution_time_score=float(form.get("resolution_time_score", 80)),
|
||
diagnosis_accuracy=float(form.get("diagnosis_accuracy", 85)),
|
||
reopen_rate_score=float(form.get("reopen_rate_score", 90)),
|
||
risk_coverage_score=float(form.get("risk_coverage_score", 75)),
|
||
)
|
||
grade = await calc_engine.engineer_grade(db, result)
|
||
return JSONResponse({"result": f"Engineer Score: {result} — {grade}"})
|
||
|
||
elif formula_name == "ecs":
|
||
result = await calc_engine.calc_engineer_control_score(
|
||
db,
|
||
sla_control=float(form.get("sla_control", 85)),
|
||
task_dist=float(form.get("task_dist", 80)),
|
||
incident_red=float(form.get("incident_red", 70)),
|
||
team_perf=float(form.get("team_perf", 75)),
|
||
response_coord=float(form.get("response_coord", 80)),
|
||
)
|
||
return JSONResponse({"result": f"ECS: {result} баллов"})
|
||
|
||
elif formula_name == "shs":
|
||
result = await calc_engine.calc_shs(
|
||
db,
|
||
sla_stability=float(form.get("sla_stability", 85)),
|
||
revenue_stability=float(form.get("revenue_stability", 80)),
|
||
retention=float(form.get("retention", 90)),
|
||
engineer_performance=float(form.get("engineer_performance", 75)),
|
||
incident_stability=float(form.get("incident_stability", 80)),
|
||
sales_flow=float(form.get("sales_flow", 70)),
|
||
operational_efficiency=float(form.get("operational_efficiency", 75)),
|
||
)
|
||
zone = await calc_engine.shs_zone(db, result)
|
||
return JSONResponse({"result": f"SHS: {result} — {zone['label']} ({zone['zone']})"})
|
||
|
||
elif formula_name == "ceo_shs":
|
||
result = await calc_engine.calc_ceo_shs(
|
||
db,
|
||
mrr_growth=float(form.get("mrr_growth", 80)),
|
||
sla_compliance=float(form.get("sla_compliance", 90)),
|
||
retention=float(form.get("retention", 85)),
|
||
productivity=float(form.get("productivity", 75)),
|
||
conversion=float(form.get("conversion", 70)),
|
||
incident_stability=float(form.get("incident_stability", 80)),
|
||
)
|
||
return JSONResponse({"result": f"CEO SHS: {result} баллов"})
|
||
|
||
return JSONResponse({"result": "Формула не поддерживается в песочнице"})
|
||
except Exception as e:
|
||
return JSONResponse({"result": f"Ошибка: {str(e)}"})
|
||
|
||
|
||
# ===================== TASKS =====================
|
||
|
||
@router.get("/service/tasks")
|
||
async def tasks_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer", "technician")), status_filter: str = Query(""), search: str = Query("")):
|
||
sql = select(Task).options(joinedload(Task.object), joinedload(Task.assignee))
|
||
if user["role"] == "technician": sql = sql.where(Task.assigned_to == user["id"])
|
||
if status_filter: sql = sql.where(Task.status == status_filter)
|
||
if search: sql = sql.where(or_(Task.title.ilike(f"%{search}%")))
|
||
result = await db.execute(sql.order_by(Task.created_at.desc()))
|
||
tasks = result.unique().scalars().all()
|
||
objects = (await db.execute(select(Object).where(Object.status == "active").order_by(Object.name))).scalars().all()
|
||
users = (await db.execute(select(User).where(and_(User.is_active, User.role.in_(["engineer","technician"]))).order_by(User.full_name))).scalars().all()
|
||
return templates.TemplateResponse("pages/tasks.html", ctx(request, user=user, tasks=tasks, objects=objects, users=users, status_filter=status_filter, search=search, active_page="tasks", title="Задачи"))
|
||
|
||
@router.post("/service/tasks/create")
|
||
async def tasks_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
|
||
form = await request.form()
|
||
db.add(Task(title=form.get("title"), description=form.get("description",""), object_id=int(form.get("object_id")),
|
||
assigned_to=int(form.get("assigned_to")) if form.get("assigned_to") else None,
|
||
priority=form.get("priority","P3"),
|
||
deadline=datetime.strptime(form.get("deadline"),"%Y-%m-%d").date() if form.get("deadline") else None,
|
||
created_by=user["id"]))
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/tasks", status_code=302)
|
||
|
||
@router.post("/service/tasks/close")
|
||
async def tasks_close(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer", "technician"))):
|
||
t = await db.get(Task, int((await request.form()).get("id")))
|
||
if t and (user["role"] in ("owner","engineer") or t.assigned_to == user["id"]):
|
||
t.status = "completed"; t.closed_at = datetime.now(); await db.flush()
|
||
return RedirectResponse(url="/service/tasks", status_code=302)
|
||
|
||
|
||
# ===================== REPORTS =====================
|
||
|
||
@router.get("/service/reports")
|
||
async def reports_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer", "technician")), type_filter: str = Query("")):
|
||
sql = select(Report).options(joinedload(Report.object))
|
||
if user["role"] in ("engineer","technician"): sql = sql.where(Report.created_by == user["id"])
|
||
if type_filter: sql = sql.where(Report.report_type == type_filter)
|
||
result = await db.execute(sql.order_by(Report.created_at.desc()))
|
||
reports = result.unique().scalars().all()
|
||
objects = (await db.execute(select(Object).where(Object.status.in_(["active","audit"])).order_by(Object.name))).scalars().all()
|
||
return templates.TemplateResponse("pages/reports.html", ctx(request, user=user, reports=reports, objects=objects, type_filter=type_filter, active_page="reports", title="Отчёты"))
|
||
|
||
@router.post("/service/reports/create")
|
||
async def reports_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer", "technician"))):
|
||
form = await request.form()
|
||
db.add(Report(object_id=int(form.get("object_id")), report_type=form.get("report_type","inspection"), title=form.get("title"),
|
||
description=form.get("description",""), findings=form.get("findings",""), recommendations=form.get("recommendations",""),
|
||
work_done=form.get("work_done",""), cause_classification=form.get("cause_classification",""),
|
||
status=form.get("status","draft"), result_status=form.get("result_status","ok"), created_by=user["id"]))
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/reports", status_code=302)
|
||
|
||
|
||
# ===================== INCIDENTS =====================
|
||
|
||
@router.get("/service/incidents")
|
||
async def incidents_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer", "technician")), severity: str = Query(""), status_filter: str = Query("")):
|
||
sql = select(Incident).options(joinedload(Incident.object), joinedload(Incident.assignee))
|
||
if user["role"] == "technician": sql = sql.where(Incident.assigned_to == user["id"])
|
||
if severity: sql = sql.where(Incident.severity == severity)
|
||
if status_filter: sql = sql.where(Incident.status == status_filter)
|
||
result = await db.execute(sql.order_by(Incident.created_at.desc()))
|
||
incidents = result.unique().scalars().all()
|
||
objects = (await db.execute(select(Object).where(Object.status == "active").order_by(Object.name))).scalars().all()
|
||
users = (await db.execute(select(User).where(User.is_active).order_by(User.full_name))).scalars().all()
|
||
return templates.TemplateResponse("pages/incidents.html", ctx(request, user=user, incidents=incidents, objects=objects, users=users, severity=severity, status_filter=status_filter, active_page="incidents", title="Инциденты"))
|
||
|
||
@router.post("/service/incidents/create")
|
||
async def incidents_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
|
||
form = await request.form()
|
||
db.add(Incident(title=form.get("title"), description=form.get("description",""), object_id=int(form.get("object_id")),
|
||
assigned_to=int(form.get("assigned_to")) if form.get("assigned_to") else None,
|
||
severity=form.get("severity","P3"), reported_by=user["id"]))
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/incidents", status_code=302)
|
||
|
||
@router.post("/service/incidents/resolve")
|
||
async def incidents_resolve(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer", "technician"))):
|
||
form = await request.form()
|
||
i = await db.get(Incident, int(form.get("id")))
|
||
if i: i.status = "resolved"; i.resolution = form.get("resolution",""); i.resolved_at = datetime.now(); await db.flush()
|
||
return RedirectResponse(url="/service/incidents", status_code=302)
|
||
|
||
|
||
# ===================== QUESTIONNAIRE =====================
|
||
|
||
@router.get("/service/questionnaire")
|
||
async def questionnaire_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
|
||
result = await db.execute(
|
||
select(QuestionnaireSession, User.full_name).join(User, QuestionnaireSession.created_by == User.id).order_by(QuestionnaireSession.created_at.desc()).limit(20)
|
||
)
|
||
return templates.TemplateResponse("pages/questionnaire.html", ctx(request, user=user, sessions=result.all(), active_page="questionnaire", title="Опросник"))
|
||
|
||
@router.get("/service/questionnaire/step/{step}")
|
||
async def questionnaire_step(request: Request, step: int, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer")), session_id: int = Query(None)):
|
||
items = (await db.execute(
|
||
select(QuestionnaireItem).where(and_(QuestionnaireItem.is_active, QuestionnaireItem.step == step)).order_by(QuestionnaireItem.sort_order, QuestionnaireItem.id)
|
||
)).scalars().all()
|
||
return templates.TemplateResponse("pages/questionnaire_step.html", ctx(request, user=user, items=items, step=step, session_id=session_id, active_page="questionnaire", title=f"Опросник — шаг {step}"))
|
||
|
||
|
||
@router.post("/service/questionnaire/save/{step}")
|
||
async def questionnaire_save(step: int, request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
|
||
form = await request.form()
|
||
session_id = form.get("session_id")
|
||
if session_id:
|
||
sess = await db.get(QuestionnaireSession, int(session_id))
|
||
else:
|
||
sess = QuestionnaireSession(created_by=user["id"], status="draft", current_step=step)
|
||
db.add(sess); await db.flush()
|
||
sess.current_step = step
|
||
items = (await db.execute(
|
||
select(QuestionnaireItem).where(and_(QuestionnaireItem.is_active, QuestionnaireItem.step == step))
|
||
)).scalars().all()
|
||
for item in items:
|
||
val = form.get(item.question_key)
|
||
if val is not None:
|
||
existing = (await db.execute(
|
||
select(QuestionnaireAnswer).where(and_(
|
||
QuestionnaireAnswer.session_id == sess.id,
|
||
QuestionnaireAnswer.question_key == item.question_key
|
||
))
|
||
)).scalar_one_or_none()
|
||
if existing:
|
||
existing.answer_value = val
|
||
else:
|
||
db.add(QuestionnaireAnswer(session_id=sess.id, step=step, question_key=item.question_key, answer_value=val))
|
||
await db.flush()
|
||
next_step = step + 1
|
||
if step >= 5 or form.get("action") == "complete":
|
||
sess.status = "completed"
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/questionnaire", status_code=302)
|
||
return RedirectResponse(url=f"/service/questionnaire/step/{next_step}", status_code=302)
|
||
|
||
|
||
# ===================== QUESTIONNAIRE CONFIG =====================
|
||
|
||
@router.get("/service/questionnaire-config")
|
||
async def questionnaire_config(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")), step: int = Query(None)):
|
||
sql = select(QuestionnaireItem)
|
||
if step: sql = sql.where(QuestionnaireItem.step == step)
|
||
items = (await db.execute(sql.order_by(QuestionnaireItem.step, QuestionnaireItem.sort_order, QuestionnaireItem.id))).scalars().all()
|
||
return templates.TemplateResponse("pages/questionnaire_config.html", ctx(request, user=user, items=items, sel_step=step, active_page="questionnaire-config", title="Настройка опросника"))
|
||
|
||
@router.post("/service/questionnaire-config/create")
|
||
async def questionnaire_config_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
form = await request.form()
|
||
db.add(QuestionnaireItem(step=int(form.get("step",1)), section=form.get("section",""), question_key=form.get("question_key"),
|
||
label=form.get("label"), type=form.get("type","text"), options="{}", required=form.get("required","0")=="1",
|
||
sort_order=int(form.get("sort_order",0)), help_text=form.get("help_text","")))
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/questionnaire-config", status_code=302)
|
||
|
||
@router.post("/service/questionnaire-config/edit")
|
||
async def questionnaire_config_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
form = await request.form()
|
||
qi = await db.get(QuestionnaireItem, int(form.get("id")))
|
||
if qi:
|
||
for f in ("step","section","question_key","label","type","options","required","sort_order","help_text"):
|
||
val = form.get(f)
|
||
if val is not None:
|
||
if f in ("step","sort_order"): val = int(val)
|
||
elif f == "required": val = val == "1"
|
||
setattr(qi, f, val)
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/questionnaire-config", status_code=302)
|
||
|
||
@router.post("/service/questionnaire-config/toggle")
|
||
async def questionnaire_config_toggle(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
qi = await db.get(QuestionnaireItem, int((await request.form()).get("id")))
|
||
if qi: qi.is_active = not qi.is_active; await db.flush()
|
||
return RedirectResponse(url="/service/questionnaire-config", status_code=302)
|
||
|
||
@router.post("/service/questionnaire-config/delete")
|
||
async def questionnaire_config_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
qi = await db.get(QuestionnaireItem, int((await request.form()).get("id")))
|
||
if qi: await db.delete(qi); await db.flush()
|
||
return RedirectResponse(url="/service/questionnaire-config", status_code=302)
|
||
|
||
|
||
# ===================== PASSPORTS =====================
|
||
|
||
@router.get("/service/passports")
|
||
async def passports_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
|
||
result = await db.execute(
|
||
select(ObjectPassport).options(
|
||
joinedload(ObjectPassport.object),
|
||
joinedload(ObjectPassport.creator)
|
||
).order_by(ObjectPassport.created_at.desc())
|
||
)
|
||
return templates.TemplateResponse("pages/passports.html", ctx(request, user=user, passports=result.unique().scalars().all(), active_page="passports", title="Паспорта объектов"))
|
||
|
||
|
||
# ===================== CHECKLIST =====================
|
||
|
||
@router.get("/service/checklist")
|
||
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"]))
|
||
).order_by(Task.created_at.desc())
|
||
)
|
||
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", "owner"))):
|
||
form = await request.form()
|
||
t = await db.get(Task, int(form.get("task_id")))
|
||
if t and t.assigned_to == user["id"]:
|
||
checks = {k: form.get(k) == "1" for k in ("arrival_fixed","photo_before","power_check","cameras_check","acs_check","fire_check","fault_found","fault_fixed","system_test","photo_after")}
|
||
t.status = "completed"; t.closed_at = datetime.now(); t.checklist_data = checks; t.result_notes = form.get("notes","")
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/checklist", status_code=302)
|
||
|
||
|
||
# ===================== BLOG =====================
|
||
|
||
@router.get("/service/blog")
|
||
async def blog_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
result = await db.execute(select(BlogPost, User.full_name).join(User, BlogPost.author_id == User.id).order_by(BlogPost.created_at.desc()))
|
||
return templates.TemplateResponse("pages/blog.html", ctx(request, user=user, posts=result.all(), active_page="blog", title="Управление блогом"))
|
||
|
||
@router.post("/service/blog/create")
|
||
async def blog_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
form = await request.form()
|
||
db.add(BlogPost(title=form.get("title"), slug=form.get("slug"), category=form.get("category",""),
|
||
status=form.get("status","draft"), excerpt=form.get("excerpt",""), content=form.get("content",""), author_id=user["id"]))
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/blog", status_code=302)
|
||
|
||
@router.post("/service/blog/edit")
|
||
async def blog_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
form = await request.form()
|
||
bp = await db.get(BlogPost, int(form.get("id")))
|
||
if bp:
|
||
for f in ("title","slug","category","status","excerpt","content"):
|
||
if form.get(f) is not None: setattr(bp, f, form.get(f))
|
||
if form.get("status") == "published" and not bp.published_at: bp.published_at = datetime.now()
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/blog", status_code=302)
|
||
|
||
@router.post("/service/blog/delete")
|
||
async def blog_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
bp = await db.get(BlogPost, int((await request.form()).get("id")))
|
||
if bp: await db.delete(bp); await db.flush()
|
||
return RedirectResponse(url="/service/blog", status_code=302)
|
||
|
||
|
||
# ===================== QUICK MENU =====================
|
||
|
||
@router.get("/service/api/quick-menu")
|
||
async def quick_menu_api(request: Request, db: AsyncSession = Depends(get_db)):
|
||
user = request.session.get("user")
|
||
if not user:
|
||
return JSONResponse([])
|
||
items = await load_quick_menu(db, user["role"])
|
||
return JSONResponse([{"id": i.id, "label": i.label, "url": i.url} for i in items])
|
||
|
||
|
||
@router.get("/service/quick-menu")
|
||
async def quick_menu_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
|
||
if user["role"] not in ("owner", "engineer"):
|
||
return HTMLResponse("Доступ запрещён", status_code=403)
|
||
items = await load_quick_menu(db, user["role"])
|
||
return templates.TemplateResponse("pages/quickmenu.html", ctx(request, user=user, items=items, active_page="quickmenu", title="Быстрое меню"))
|
||
|
||
|
||
@router.post("/service/quick-menu/save")
|
||
async def quick_menu_save(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
|
||
if user["role"] not in ("owner", "engineer"):
|
||
return HTMLResponse("Доступ запрещён", status_code=403)
|
||
form = await request.form()
|
||
await db.execute(delete(QuickMenuItem).where(QuickMenuItem.role == user["role"], QuickMenuItem.created_by == user["id"]))
|
||
labels = form.getlist("label[]")
|
||
urls = form.getlist("url[]")
|
||
for i, (label, url) in enumerate(zip(labels, urls)):
|
||
if label.strip() and url.strip():
|
||
db.add(QuickMenuItem(role=user["role"], label=label.strip(), url=url.strip(), sort_order=i, created_by=user["id"]))
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/quick-menu", status_code=302)
|
||
|
||
|
||
@router.get("/service/quick-menu/tech")
|
||
async def quick_menu_tech_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("engineer"))):
|
||
items = await load_quick_menu(db, "technician")
|
||
return templates.TemplateResponse("pages/quickmenu.html", ctx(request, user=user, items=items, editing_tech=True, active_page="quickmenu-tech", title="Меню для техников"))
|
||
|
||
|
||
@router.post("/service/quick-menu/tech-save")
|
||
async def quick_menu_tech_save(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("engineer"))):
|
||
form = await request.form()
|
||
await db.execute(delete(QuickMenuItem).where(QuickMenuItem.role == "technician"))
|
||
labels = form.getlist("label[]")
|
||
urls = form.getlist("url[]")
|
||
for i, (label, url) in enumerate(zip(labels, urls)):
|
||
if label.strip() and url.strip():
|
||
db.add(QuickMenuItem(role="technician", label=label.strip(), url=url.strip(), sort_order=i, created_by=user["id"]))
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/quick-menu/tech", status_code=302)
|
||
|
||
|
||
# ===================== IDEAS =====================
|
||
|
||
@router.get("/service/ideas")
|
||
async def ideas_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
result = await db.execute(select(Idea).order_by(Idea.status, Idea.updated_at.desc()))
|
||
return templates.TemplateResponse("pages/ideas.html", ctx(request, user=user, ideas=result.scalars().all(), active_page="ideas", title="Идеи"))
|
||
|
||
|
||
@router.post("/service/ideas/create")
|
||
async def ideas_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
form = await request.form()
|
||
db.add(Idea(title=form.get("title"), description=form.get("description",""), created_by=user["id"]))
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/ideas", status_code=302)
|
||
|
||
|
||
@router.post("/service/ideas/edit")
|
||
async def ideas_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
form = await request.form()
|
||
try:
|
||
idea_id = int(form.get("id"))
|
||
except (ValueError, TypeError):
|
||
return RedirectResponse(url="/service/ideas", status_code=302)
|
||
idea = await db.get(Idea, idea_id)
|
||
if idea:
|
||
idea.title = form.get("title", idea.title)
|
||
idea.description = form.get("description", idea.description)
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/ideas", status_code=302)
|
||
|
||
|
||
@router.post("/service/ideas/status")
|
||
async def ideas_status(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
form = await request.form()
|
||
try:
|
||
idea_id = int(form.get("id"))
|
||
except (TypeError, ValueError):
|
||
return RedirectResponse(url="/service/ideas", status_code=302)
|
||
idea = await db.get(Idea, idea_id)
|
||
if idea:
|
||
statuses = {"normal": "in_progress", "in_progress": "completed", "completed": "normal"}
|
||
idea.status = statuses.get(idea.status, "normal")
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/ideas", status_code=302)
|
||
|
||
|
||
@router.post("/service/ideas/delete")
|
||
async def ideas_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
idea = await db.get(Idea, int((await request.form()).get("id")))
|
||
if idea:
|
||
await db.delete(idea)
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/ideas", status_code=302)
|
||
|
||
|
||
# ===================== CEO CALC STUBS =====================
|
||
|
||
@router.post("/service/ceo/calc")
|
||
async def ceo_calc(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
# SHS stub — until the calculation pipeline is designed
|
||
return JSONResponse({"status": "stub", "message": "Расчёт SHS будет доработан после согласования формул с заказчиком"})
|
||
|
||
|
||
@router.post("/service/ceo/kpi")
|
||
async def ceo_kpi(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
# KPI stub — until the calculation pipeline is designed
|
||
return JSONResponse({"status": "stub", "message": "Расчёт KPI будет доработан после согласования формул с заказчиком"})
|
||
|
||
|
||
# ===================== CHARTS =====================
|
||
|
||
import calendar
|
||
from collections import defaultdict
|
||
|
||
|
||
@router.get("/service/charts")
|
||
async def charts_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
return templates.TemplateResponse("pages/charts.html", ctx(request, user=user, active_page="charts", title="Графики и диаграммы"))
|
||
|
||
|
||
@router.get("/service/api/charts/data")
|
||
async def charts_data(
|
||
metric: str = Query("tasks"),
|
||
group: str = Query("month"),
|
||
db: AsyncSession = Depends(get_db),
|
||
user: dict = Depends(require_role("owner")),
|
||
):
|
||
from app.models.models import Task, Incident, SHSRecord, EngineerKPI, SLAContract, Object
|
||
import datetime
|
||
|
||
labels = []
|
||
values = []
|
||
datasets = []
|
||
|
||
if metric == "tasks":
|
||
result = await db.execute(
|
||
select(func.date_trunc(group, Task.created_at).label("period"), func.count(Task.id))
|
||
.where(Task.created_at != None)
|
||
.group_by(text("period"))
|
||
.order_by(text("period"))
|
||
)
|
||
rows = result.all()
|
||
labels = [r[0].strftime("%Y-%m") if r[0] else "" for r in rows]
|
||
values = [r[1] for r in rows]
|
||
datasets = [
|
||
{"label": "Все задачи", "data": values},
|
||
]
|
||
# Also get by status
|
||
for status in ("open", "in_progress", "completed"):
|
||
r2 = await db.execute(
|
||
select(func.date_trunc(group, Task.created_at).label("period"), func.count(Task.id))
|
||
.where(Task.status == status, Task.created_at != None)
|
||
.group_by(text("period"))
|
||
.order_by(text("period"))
|
||
)
|
||
rows2 = r2.all()
|
||
if rows2:
|
||
datasets.append({"label": status, "data": [r[1] for r in rows2]})
|
||
|
||
elif metric == "incidents":
|
||
for sev in ("P1", "P2", "P3"):
|
||
result = await db.execute(
|
||
select(func.date_trunc(group, Incident.created_at).label("period"), func.count(Incident.id))
|
||
.where(Incident.severity == sev, Incident.created_at != None)
|
||
.group_by(text("period"))
|
||
.order_by(text("period"))
|
||
)
|
||
rows = result.all()
|
||
if rows:
|
||
if not labels:
|
||
labels = [r[0].strftime("%Y-%m") if r[0] else "" for r in rows]
|
||
datasets.append({"label": sev, "data": [r[1] for r in rows]})
|
||
|
||
elif metric == "shs":
|
||
result = await db.execute(
|
||
select(SHSRecord).order_by(SHSRecord.id.asc())
|
||
)
|
||
records = result.scalars().all()
|
||
labels = [r.period_end.strftime("%Y-%m") if r.period_end else "" for r in records]
|
||
datasets = [
|
||
{"label": "SHS Score", "data": [float(r.shs_score) for r in records]},
|
||
{"label": "SLA Stability", "data": [float(r.sla_stability) for r in records]},
|
||
{"label": "Revenue Stability", "data": [float(r.revenue_stability) for r in records]},
|
||
]
|
||
|
||
elif metric == "kpi":
|
||
result = await db.execute(
|
||
select(EngineerKPI).order_by(EngineerKPI.period_end.asc()).limit(50)
|
||
)
|
||
kpis = result.scalars().all()
|
||
labels = [r.period_end.strftime("%Y-%m") for r in kpis]
|
||
datasets = [
|
||
{"label": "Engineer Score", "data": [float(r.engineer_score or 0) for r in kpis]},
|
||
{"label": "SLA Compliance", "data": [float(r.sla_compliance or 0) for r in kpis]},
|
||
]
|
||
|
||
elif metric == "mrr":
|
||
result = await db.execute(
|
||
select(SLAContract.status, func.sum(SLAContract.sla_price_monthly))
|
||
.group_by(SLAContract.status)
|
||
)
|
||
rows = result.all()
|
||
labels = [r[0] for r in rows]
|
||
values = [float(r[1] or 0) for r in rows]
|
||
datasets = [{"label": "MRR", "data": values}]
|
||
|
||
elif metric == "objects":
|
||
result = await db.execute(
|
||
select(Object.status, func.count(Object.id)).group_by(Object.status)
|
||
)
|
||
rows = result.all()
|
||
labels = [r[0] for r in rows]
|
||
values = [r[1] for r in rows]
|
||
datasets = [{"label": "Объекты", "data": values}]
|
||
|
||
return JSONResponse({"labels": labels, "datasets": datasets})
|
||
|
||
|
||
# ===================== CASES =====================
|
||
|
||
@router.get("/service/cases")
|
||
async def cases_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
cases = (await db.execute(select(Case).order_by(Case.sort_order, Case.id))).scalars().all()
|
||
return templates.TemplateResponse("pages/cases.html", ctx(request, user=user, cases=cases, active_page="cases", title="Примеры из практики"))
|
||
|
||
@router.post("/service/cases/create")
|
||
async def cases_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
form = await request.form()
|
||
db.add(Case(title=form.get("title"), text=form.get("text"), effect=form.get("effect",""), sort_order=int(form.get("sort_order",0)), is_active=form.get("is_active","1")=="1"))
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/cases", status_code=302)
|
||
|
||
@router.post("/service/cases/edit")
|
||
async def cases_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
form = await request.form()
|
||
c = await db.get(Case, int(form.get("id")))
|
||
if c:
|
||
for f in ("title","text","effect","sort_order","is_active"):
|
||
val = form.get(f)
|
||
if val is not None:
|
||
if f == "sort_order": val = int(val)
|
||
elif f == "is_active": val = val == "1"
|
||
setattr(c, f, val)
|
||
await db.flush()
|
||
return RedirectResponse(url="/service/cases", status_code=302)
|
||
|
||
@router.post("/service/cases/delete")
|
||
async def cases_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
c = await db.get(Case, int((await request.form()).get("id")))
|
||
if c: await db.delete(c); await db.flush()
|
||
return RedirectResponse(url="/service/cases", status_code=302)
|
||
|
||
|
||
@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="Настройка Ролей"))
|
||
|
||
|
||
# ===================== CHAT-BOT MAX PAGES =====================
|
||
|
||
@router.get("/service/bot-settings")
|
||
async def bot_settings_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
|
||
return templates.TemplateResponse("pages/bot_settings.html", ctx(request, user=user, active_page="bot-settings", title="Настройки чат-бота MAX"))
|
||
|
||
|
||
@router.get("/service/bot-kb")
|
||
async def bot_kb_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
|
||
return templates.TemplateResponse("pages/bot_kb.html", ctx(request, user=user, active_page="bot-kb", title="База знаний чат-бота"))
|
||
|
||
|
||
@router.get("/service/bot-unknown")
|
||
async def bot_unknown_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
|
||
return templates.TemplateResponse("pages/bot_unknown.html", ctx(request, user=user, active_page="bot-unknown", title="Неизвестные вопросы"))
|
||
|
||
|
||
@router.get("/service/bot-test")
|
||
async def bot_test_page_redirect():
|
||
return RedirectResponse(url="/service/bot-settings", status_code=302)
|
||
|
||
|
||
@router.get("/service/bot-test-content")
|
||
async def bot_test_content(request: Request, user: dict = Depends(get_current_user)):
|
||
if not user:
|
||
return HTMLResponse("Not authenticated")
|
||
return templates.TemplateResponse("pages/bot_test.html", {"request": request})
|
||
|
||
|
||
@router.get("/service/bot-consent")
|
||
async def bot_consent_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
|
||
return templates.TemplateResponse("pages/bot_consent.html", ctx(request, user=user, active_page="bot-consent", title="Согласия ФЗ-152 и рассылка"))
|
||
|
||
|
||
@router.get("/service/bot-tickets")
|
||
async def bot_tickets_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
|
||
return templates.TemplateResponse("pages/bot_tickets.html", ctx(request, user=user, active_page="bot-tickets", title="Заявки от чат-бота"))
|
||
|
||
|
||
@router.get("/service/bot-dialogues")
|
||
async def bot_dialogues_page(request: Request, user: dict = Depends(get_current_user)):
|
||
if not user:
|
||
return RedirectResponse(url="/service/login", status_code=302)
|
||
return templates.TemplateResponse("pages/bot_dialogues.html", ctx(request, user=user, active_page="bot-dialogues", 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})
|
||
|
||
|
||
import re
|
||
import os
|
||
|
||
def parse_sidebar_menu():
|
||
sidebar_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates", "_sidebar.html")
|
||
try:
|
||
with open(sidebar_path, "r", encoding="utf-8") as f:
|
||
html = f.read()
|
||
except FileNotFoundError:
|
||
return []
|
||
items = []
|
||
current_section = ""
|
||
for line in html.split("\n"):
|
||
sec = re.search(r'sidebar-section[^>]*>([^<]+)', line)
|
||
if sec:
|
||
current_section = sec.group(1).strip()
|
||
perm = re.search(r"role_permissions\.get\('(\w+)'", line)
|
||
label = re.search(r'>([^<]+)</a>', line)
|
||
if perm and label:
|
||
items.append({"key": perm.group(1), "label": label.group(1).strip(), "section": current_section})
|
||
return items
|
||
|
||
|
||
@router.get("/service/api/menu-items")
|
||
async def api_menu_items(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
|
||
from app.models.models import RoleMenuPermission
|
||
from sqlalchemy import select as sa_select
|
||
items = parse_sidebar_menu()
|
||
existing = await db.execute(sa_select(RoleMenuPermission))
|
||
existing_keys = {(r.role, r.menu_key) for r in existing.scalars().all()}
|
||
for item in items:
|
||
for role in ("engineer", "technician"):
|
||
if (role, item["key"]) not in existing_keys:
|
||
db.add(RoleMenuPermission(role=role, menu_key=item["key"], visible=False))
|
||
await db.commit()
|
||
return JSONResponse(items)
|
||
|
||
|
||
# ===================== 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 PROXY =====================
|
||
BOT_SERVICE_URL = "http://127.0.0.1:8002"
|
||
|
||
|
||
async def _proxy(method: str, path: str, body=None):
|
||
async with httpx.AsyncClient() as client:
|
||
if body is not None:
|
||
resp = await client.request(method, f"{BOT_SERVICE_URL}{path}", json=body)
|
||
else:
|
||
resp = await client.request(method, f"{BOT_SERVICE_URL}{path}")
|
||
return JSONResponse(resp.json(), status_code=resp.status_code)
|
||
|
||
|
||
@router.get("/service/api/bot/settings")
|
||
async def proxy_bot_settings_get(user: dict = Depends(get_current_user)):
|
||
if not user:
|
||
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
|
||
return await _proxy("GET", "/api/bot/settings")
|
||
|
||
|
||
@router.post("/service/api/bot/settings/save")
|
||
async def proxy_bot_settings_save(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()
|
||
return await _proxy("POST", "/api/bot/settings/save", body)
|
||
|
||
|
||
@router.get("/api/bot/kb")
|
||
async def proxy_bot_kb_list(user: dict = Depends(get_current_user)):
|
||
if not user:
|
||
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
|
||
return await _proxy("GET", "/api/bot/kb")
|
||
|
||
|
||
@router.post("/api/bot/kb")
|
||
async def proxy_bot_kb_create(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()
|
||
return await _proxy("POST", "/api/bot/kb", body)
|
||
|
||
|
||
@router.delete("/api/bot/kb/{card_id}")
|
||
async def proxy_bot_kb_delete(card_id: int, user: dict = Depends(get_current_user)):
|
||
if not user:
|
||
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
|
||
return await _proxy("DELETE", f"/api/bot/kb/{card_id}")
|
||
|
||
|
||
@router.get("/api/bot/tickets")
|
||
async def proxy_bot_tickets_list(user: dict = Depends(get_current_user)):
|
||
if not user:
|
||
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
|
||
return await _proxy("GET", "/api/bot/tickets")
|
||
|
||
|
||
@router.patch("/api/bot/tickets/{ticket_id}")
|
||
async def proxy_bot_tickets_update(ticket_id: int, 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()
|
||
return await _proxy("PATCH", f"/api/bot/tickets/{ticket_id}", body)
|
||
|
||
|
||
@router.put("/api/bot/kb/{card_id}")
|
||
async def proxy_bot_kb_update(card_id: int, 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()
|
||
return await _proxy("PUT", f"/api/bot/kb/{card_id}", body)
|
||
|
||
|
||
@router.get("/api/bot/users/consented")
|
||
async def proxy_bot_users_consented(user: dict = Depends(get_current_user)):
|
||
if not user:
|
||
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
|
||
return await _proxy("GET", "/api/bot/users/consented")
|
||
|
||
|
||
@router.post("/api/bot/broadcast")
|
||
async def proxy_bot_broadcast(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()
|
||
return await _proxy("POST", "/api/bot/broadcast", body)
|
||
|
||
|
||
@router.post("/api/bot/broadcast/preview")
|
||
async def proxy_bot_broadcast_preview(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()
|
||
return await _proxy("POST", "/api/bot/broadcast/preview", body)
|
||
|
||
|
||
@router.post("/api/bot/test/clear")
|
||
async def proxy_bot_test_clear(user: dict = Depends(get_current_user)):
|
||
if not user:
|
||
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
|
||
return await _proxy("POST", "/api/bot/test/clear")
|
||
|
||
|
||
@router.post("/api/bot/webhook")
|
||
async def proxy_bot_webhook(request: Request):
|
||
body = await request.json()
|
||
headers = {"Content-Type": "application/json"}
|
||
secret = request.headers.get("X-Max-Bot-Api-Secret")
|
||
if secret:
|
||
headers["X-Max-Bot-Api-Secret"] = secret
|
||
try:
|
||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||
resp = await client.post(f"{BOT_SERVICE_URL}/webhook", json=body, headers=headers)
|
||
return JSONResponse(resp.json(), status_code=resp.status_code)
|
||
except httpx.TimeoutException:
|
||
return JSONResponse({"ok": False, "error": "Max bot timeout"}, status_code=504)
|
||
except Exception as e:
|
||
return JSONResponse({"ok": False, "error": str(e)}, status_code=502)
|
||
|
||
|
||
@router.get("/api/bot/conversations")
|
||
async def proxy_bot_conversations(user: dict = Depends(get_current_user)):
|
||
if not user:
|
||
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
|
||
return await _proxy("GET", "/api/bot/conversations")
|
||
|
||
|
||
@router.get("/api/bot/conversations/{conv_id}/messages")
|
||
async def proxy_bot_conversation_messages(conv_id: int, user: dict = Depends(get_current_user)):
|
||
if not user:
|
||
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
|
||
return await _proxy("GET", f"/api/bot/conversations/{conv_id}/messages")
|
||
|
||
|
||
@router.get("/api/bot/conversations/{conv_id}/logs")
|
||
async def proxy_bot_conversation_logs(conv_id: int, user: dict = Depends(get_current_user)):
|
||
if not user:
|
||
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
|
||
return await _proxy("GET", f"/api/bot/conversations/{conv_id}/logs")
|
||
|
||
|
||
@router.delete("/api/bot/conversations/{conv_id}")
|
||
async def proxy_delete_conversation(conv_id: int, request: Request, user: dict = Depends(require_role("owner"))):
|
||
return await _proxy("DELETE", f"/api/bot/conversations/{conv_id}")
|
||
|
||
|