Files
site_aegisone/py_service/app/main.py
T

330 lines
15 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
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"}