342 lines
16 KiB
Python
342 lines
16 KiB
Python
"""
|
|
Главный модуль service portal FastAPI-приложения.
|
|
|
|
Портал для управления:
|
|
- Сотрудниками (owner/engineer/technician)
|
|
- Клиентами и объектами
|
|
- Задачами и инцидентами
|
|
- Настройками бота и базой знаний
|
|
- Документами и отчётами
|
|
"""
|
|
import os
|
|
import asyncio
|
|
from contextlib import asynccontextmanager
|
|
from fastapi import FastAPI, Request, Depends
|
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from app.config import get_settings
|
|
from app.database import get_db, async_session
|
|
from app.auth import audit_log
|
|
from app.templating import templates
|
|
from app.services.docs_permissions import discover_docs
|
|
from app.documents_router import router as documents_router
|
|
from app.dependencies import get_current_user, require_role
|
|
from app.version import get_app_version
|
|
|
|
settings = get_settings()
|
|
APP_VERSION = get_app_version()
|
|
|
|
|
|
SEED_COEFFICIENTS = [
|
|
("risk_score.no_archive", 25, "Нет архива видеонаблюдения", "calc_risk_score()"),
|
|
("risk_score.no_power_backup", 20, "Нет резервного питания", "calc_risk_score()"),
|
|
("risk_score.no_regulations", 15, "Нет регламента обслуживания", "calc_risk_score()"),
|
|
("risk_score.system_failures", 20, "Частые сбои систем", "calc_risk_score()"),
|
|
("risk_score.no_documentation", 10, "Нет документации", "calc_risk_score()"),
|
|
("risk_score.bound_low", 20, "Граница низкого риска", "risk_label()"),
|
|
("risk_score.bound_medium", 50, "Граница среднего риска", "risk_label()"),
|
|
("risk_score.bound_high", 75, "Граница высокого риска", "risk_label()"),
|
|
("risk_multiplier.low", 1.0, "Множитель низкого риска (0-20)", "risk_multiplier()"),
|
|
("risk_multiplier.medium", 1.3, "Множитель среднего риска (21-50)", "risk_multiplier()"),
|
|
("risk_multiplier.high", 1.6, "Множитель высокого риска (51-75)", "risk_multiplier()"),
|
|
("risk_multiplier.critical", 2.0, "Множитель критического риска (76-100)", "risk_multiplier()"),
|
|
("complexity.video_0_20", 10, "Видеонаблюдение до 20 камер", "calc_complexity()"),
|
|
("complexity.video_20_100", 20, "Видеонаблюдение 20-100 камер", "calc_complexity()"),
|
|
("complexity.video_100plus", 35, "Видеонаблюдение 100+ камер", "calc_complexity()"),
|
|
("complexity.access_0_5", 10, "СКУД до 5 точек", "calc_complexity()"),
|
|
("complexity.access_5_20", 20, "СКУД 5-20 точек", "calc_complexity()"),
|
|
("complexity.access_20plus", 30, "СКУД 20+ точек", "calc_complexity()"),
|
|
("complexity.fire_simple", 15, "Пожарная сигнализация простая", "calc_complexity()"),
|
|
("complexity.fire_medium", 25, "Пожарная сигнализация средняя", "calc_complexity()"),
|
|
("complexity.fire_complex", 40, "Пожарная сигнализация сложная (>5000 м²)", "calc_complexity()"),
|
|
("complexity.it", 10, "IT-инфраструктура", "calc_complexity()"),
|
|
("infra.server_none", 15, "Нет сервера", "calc_infrastructure_load()"),
|
|
("infra.server_weak", 10, "Слабый сервер", "calc_infrastructure_load()"),
|
|
("infra.network_unstable", 20, "Нестабильная сеть", "calc_infrastructure_load()"),
|
|
("infra.network_partial", 10, "Частичная сеть", "calc_infrastructure_load()"),
|
|
("infra.power_none", 20, "Нет UPS", "calc_infrastructure_load()"),
|
|
("infra.power_weak", 10, "Слабый UPS", "calc_infrastructure_load()"),
|
|
("history.none", 30, "Нет обслуживания", "calc_service_history()"),
|
|
("history.irregular", 20, "Нерегулярное обслуживание", "calc_service_history()"),
|
|
("history.formal", 10, "Формальный подрядчик", "calc_service_history()"),
|
|
("history.sla", 0, "Есть SLA", "calc_service_history()"),
|
|
("object_index.risk_weight", 0.4, "Вес Risk Score", "calc_object_index()"),
|
|
("object_index.complexity_weight", 0.3, "Вес Complexity", "calc_object_index()"),
|
|
("object_index.infra_weight", 0.2, "Вес Infra Load", "calc_object_index()"),
|
|
("object_index.history_weight", 0.1, "Вес Service History", "calc_object_index()"),
|
|
("sla_price.base_cost", 15000, "Базовая стоимость инженера", "calc_sla_price()"),
|
|
("sla_price.region_default", 1.0, "Региональный коэффициент по умолчанию", "calc_sla_price()"),
|
|
]
|
|
|
|
|
|
async def seed_initial_data():
|
|
from sqlalchemy import text, select, func
|
|
from app.models.models import FormulaCoefficient, User, RoleMenuPermission
|
|
|
|
async with async_session() as db:
|
|
try:
|
|
count = await db.execute(select(func.count(FormulaCoefficient.id)))
|
|
if count.scalar() == 0:
|
|
from app.models.models import FormulaCoefficient as FC
|
|
for key, value, desc, ref in SEED_COEFFICIENTS:
|
|
db.add(FC(key=key, value=value, description=desc, formula_ref=ref))
|
|
await db.commit()
|
|
|
|
# Seed default role menu permissions if table is empty
|
|
perm_count = await db.execute(select(func.count(RoleMenuPermission.id)))
|
|
if perm_count.scalar() == 0:
|
|
DEFAULT_PERMISSIONS = {
|
|
"engineer": [
|
|
"charts", "customers", "objects", "sla", "questionnaire", "passports",
|
|
"tasks", "reports", "incidents", "tech_access", "docs",
|
|
|
|
],
|
|
"technician": [
|
|
"tasks", "reports", "incidents", "checklist",
|
|
],
|
|
}
|
|
for role, keys in DEFAULT_PERMISSIONS.items():
|
|
for key in keys:
|
|
db.add(RoleMenuPermission(role=role, menu_key=key, visible=True))
|
|
await db.commit()
|
|
|
|
await db.execute(text("DELETE FROM users WHERE login = 'admin'"))
|
|
await db.commit()
|
|
except Exception:
|
|
await db.rollback()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
from app.models.models import Base
|
|
from app.database import engine
|
|
|
|
# Применяем миграции (ALTER TABLE, CREATE TABLE IF NOT EXISTS)
|
|
from app.migrations_runner import run_migrations_on_startup
|
|
migration_results = await run_migrations_on_startup()
|
|
if migration_results:
|
|
import logging
|
|
logging.getLogger("migrations").info(
|
|
"Применены миграции: %s", ", ".join(migration_results)
|
|
)
|
|
|
|
# Создаём таблицы которые ещё не существуют (idempotent)
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
await seed_initial_data()
|
|
async with async_session() as db:
|
|
from app.calculations.engine import reload_coefficients
|
|
await reload_coefficients(db)
|
|
discover_docs()
|
|
|
|
yield
|
|
|
|
|
|
app = FastAPI(title="AegisOne Service Portal", lifespan=lifespan)
|
|
|
|
app.mount("/service/static", StaticFiles(directory="app/static"), name="static")
|
|
|
|
|
|
class RoleGuardMiddleware(BaseHTTPMiddleware):
|
|
async def dispatch(self, request: Request, call_next):
|
|
path = request.url.path
|
|
if path.startswith("/service/") and not any(
|
|
path.startswith(p) for p in ["/service/login", "/service/static", "/service/api/"]
|
|
):
|
|
if "session" in request.scope:
|
|
user = request.session.get("user")
|
|
if not user:
|
|
return RedirectResponse(url="/service/login", status_code=302)
|
|
response = await call_next(request)
|
|
return response
|
|
|
|
|
|
app.add_middleware(RoleGuardMiddleware)
|
|
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY, max_age=settings.SESSION_TTL)
|
|
|
|
|
|
class PermissionsMiddleware(BaseHTTPMiddleware):
|
|
async def dispatch(self, request: Request, call_next):
|
|
if "session" not in request.scope:
|
|
request.state.role_permissions = {}
|
|
return await call_next(request)
|
|
from app.models.models import RoleMenuPermission
|
|
from sqlalchemy import select
|
|
user = request.session.get("user")
|
|
if user and user.get("role") in ("engineer", "technician"):
|
|
from app.database import async_session
|
|
async with async_session() as db:
|
|
result = await db.execute(
|
|
select(RoleMenuPermission).where(RoleMenuPermission.role == user["role"])
|
|
)
|
|
request.state.role_permissions = {
|
|
row.menu_key: row.visible for row in result.scalars().all()
|
|
}
|
|
else:
|
|
request.state.role_permissions = {}
|
|
response = await call_next(request)
|
|
return response
|
|
|
|
|
|
app.add_middleware(PermissionsMiddleware)
|
|
|
|
app.include_router(documents_router)
|
|
|
|
from app.routers.service_pages import router as service_pages_router
|
|
app.include_router(service_pages_router)
|
|
|
|
|
|
@app.get("/")
|
|
async def root(request: Request):
|
|
if request.session.get("user"):
|
|
return RedirectResponse(url="/service/dashboard", status_code=302)
|
|
return RedirectResponse(url="/service/login", status_code=302)
|
|
|
|
|
|
@app.get("/service/login")
|
|
async def login_page(request: Request, db: AsyncSession = Depends(get_db)):
|
|
if request.session.get("user"):
|
|
return RedirectResponse(url="/service/dashboard", status_code=302)
|
|
return templates.TemplateResponse("login.html", {"request": request, "error": ""})
|
|
|
|
|
|
@app.post("/service/login")
|
|
async def login_submit(request: Request, db: AsyncSession = Depends(get_db)):
|
|
form = await request.form()
|
|
login = form.get("login", "").strip()
|
|
password = form.get("password", "")
|
|
if not login or not password:
|
|
return templates.TemplateResponse("login.html", {"request": request, "error": "Заполните все поля."})
|
|
|
|
from app.auth import authenticate
|
|
ip = request.client.host if request.client else ""
|
|
result = await authenticate(db, login, password, ip)
|
|
|
|
if result["success"]:
|
|
request.session["user"] = result["user"]
|
|
request.session["csrf_token"] = __import__("secrets").token_hex(32)
|
|
redirect = request.query_params.get("redirect", "/service/dashboard")
|
|
return RedirectResponse(url=redirect, status_code=302)
|
|
return templates.TemplateResponse("login.html", {"request": request, "error": result["error"]})
|
|
|
|
|
|
@app.get("/service/logout")
|
|
async def logout(request: Request, db: AsyncSession = Depends(get_db)):
|
|
user = request.session.get("user")
|
|
if user:
|
|
await audit_log(db, user["id"], "logout", "Выход из системы", request.client.host if request.client else "")
|
|
request.session.clear()
|
|
return RedirectResponse(url="/service/login", status_code=302)
|
|
|
|
|
|
@app.get("/service/dashboard")
|
|
async def dashboard(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
|
|
from sqlalchemy import select, func, Integer
|
|
from app.models.models import Object, Task, Incident, SLAContract, SHSRecord
|
|
|
|
stats = {}
|
|
result = await db.execute(select(func.count(Object.id)).where(Object.status == "active"))
|
|
stats["objects"] = result.scalar() or 0
|
|
|
|
result = await db.execute(select(func.count(Task.id)).where(Task.status.in_(["open", "in_progress"])))
|
|
stats["tasks"] = result.scalar() or 0
|
|
|
|
result = await db.execute(select(func.count(Incident.id)).where(Incident.status.in_(["open", "in_progress"])))
|
|
stats["incidents"] = result.scalar() or 0
|
|
|
|
result = await db.execute(select(func.count(SLAContract.id)).where(SLAContract.status == "active"))
|
|
stats["sla_count"] = result.scalar() or 0
|
|
|
|
result = await db.execute(select(SHSRecord.shs_score, SHSRecord.shs_status).order_by(SHSRecord.id.desc()).limit(1))
|
|
shs_row = result.first()
|
|
stats["shs"] = {"shs_score": shs_row[0], "shs_status": shs_row[1]} if shs_row else None
|
|
|
|
if user["role"] in ("engineer", "technician"):
|
|
result = await db.execute(
|
|
select(func.count(Task.id)).where(
|
|
Task.assigned_to == user["id"],
|
|
Task.status.in_(["open", "in_progress"]),
|
|
)
|
|
)
|
|
stats["my_tasks"] = result.scalar() or 0
|
|
|
|
result = await db.execute(
|
|
select(func.count(Task.id), func.sum(Task.sla_compliant.cast(Integer)))
|
|
.where(Task.closed_at != None)
|
|
)
|
|
row = result.first()
|
|
total = row[0] or 0
|
|
compliant = row[1] or 0
|
|
stats["sla_compliance"] = round((compliant / total) * 100, 1) if total > 0 else 0
|
|
|
|
shs_alert = None
|
|
if stats["shs"] and stats["shs"]["shs_score"] < 70:
|
|
shs_alert = f"SHS ниже 70 ({stats['shs']['shs_score']}) — {stats['shs']['shs_status']}"
|
|
|
|
now = __import__("datetime").datetime.now().strftime("%d.%m.%Y %H:%M")
|
|
original_user = request.session.get("original_user")
|
|
available_roles = {"owner": ["owner", "engineer", "technician"], "engineer": ["engineer", "technician"]}
|
|
return templates.TemplateResponse("dashboard.html", {
|
|
"request": request, "stats": stats, "shs_alert": shs_alert, "now": now, "user": user,
|
|
"active_page": "dashboard",
|
|
"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 [],
|
|
})
|
|
|
|
|
|
@app.get("/service/api/shs")
|
|
async def api_shs(db: AsyncSession = Depends(get_db)):
|
|
from sqlalchemy import select
|
|
from app.models.models import SHSRecord
|
|
result = await db.execute(select(SHSRecord).order_by(SHSRecord.id.desc()).limit(1))
|
|
record = result.scalar_one_or_none()
|
|
if not record:
|
|
return JSONResponse({"error": "No data"})
|
|
return JSONResponse({
|
|
"shs_score": float(record.shs_score),
|
|
"shs_status": record.shs_status,
|
|
"period_start": record.period_start.isoformat(),
|
|
"period_end": record.period_end.isoformat(),
|
|
})
|
|
|
|
|
|
@app.get("/service/api/tasks")
|
|
async def api_tasks(db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
|
|
from sqlalchemy import select
|
|
from app.models.models import Task
|
|
query = select(Task).where(Task.status.in_(["open", "in_progress"]))
|
|
if user["role"] == "technician":
|
|
query = query.where(Task.assigned_to == user["id"])
|
|
result = await db.execute(query.order_by(Task.created_at.desc()))
|
|
tasks = result.scalars().all()
|
|
return JSONResponse([
|
|
{
|
|
"id": t.id, "title": t.title, "priority": t.priority,
|
|
"status": t.status, "object_id": t.object_id,
|
|
}
|
|
for t in tasks
|
|
])
|
|
|
|
|
|
@app.get("/service/api/changelog")
|
|
async def api_changelog():
|
|
import os, markdown
|
|
base = os.path.dirname(os.path.abspath(__file__))
|
|
changelog_path = os.path.join(base, "..", "CHANGELOG.md")
|
|
if not os.path.isfile(changelog_path):
|
|
return JSONResponse({"html": "<p>Файл изменений не найден.</p>"})
|
|
with open(changelog_path, encoding="utf-8") as f:
|
|
html = markdown.markdown(f.read(), extensions=["fenced_code", "tables"])
|
|
return JSONResponse({"html": html})
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "ok"}
|