v1.4.0: fix #43, #31, #29, #49, #33 + shared templating

This commit is contained in:
2026-05-20 20:08:25 +03:00
parent ca4d00c895
commit 64991f0228
63 changed files with 5005 additions and 45 deletions
+102 -23
View File
@@ -3,20 +3,90 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Depends
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
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
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()
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)
@@ -26,10 +96,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="AegisOne Service Portal", lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY, max_age=settings.SESSION_TTL)
templates = Jinja2Templates(directory="app/templates")
templates.env.globals["SITE_NAME"] = "AegisOne Engineering"
app.mount("/service/static", StaticFiles(directory="app/static"), name="static")
class RoleGuardMiddleware(BaseHTTPMiddleware):
@@ -38,28 +105,28 @@ class RoleGuardMiddleware(BaseHTTPMiddleware):
if path.startswith("/service/") and not any(
path.startswith(p) for p in ["/service/login", "/service/static", "/service/api/"]
):
user = request.session.get("user")
if not user:
return RedirectResponse(url="/service/login", status_code=302)
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)
app.include_router(documents_router)
from app.routers.service_pages import router as service_pages_router
app.include_router(service_pages_router)
def get_current_user(request: Request) -> dict | None:
return request.session.get("user")
def require_role(*roles: str):
def dependency(request: Request, user: dict = Depends(get_current_user)):
if not user:
return RedirectResponse(url="/service/login", status_code=302)
if roles and user["role"] not in roles:
return HTMLResponse(content="Доступ запрещён", status_code=403)
return user
return dependency
@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")
@@ -100,7 +167,7 @@ async def logout(request: Request, db: AsyncSession = Depends(get_db)):
@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
from sqlalchemy import select, func, Integer
from app.models.models import Object, Task, Incident, SLAContract, SHSRecord
stats = {}
@@ -130,8 +197,8 @@ async def dashboard(request: Request, db: AsyncSession = Depends(get_db), user:
stats["my_tasks"] = result.scalar() or 0
result = await db.execute(
select(func.count(Task.id), func.sum(Task.sla_compliant.cast(int)))
.where(Task.closed_at.isnot(None))
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
@@ -182,6 +249,18 @@ async def api_tasks(db: AsyncSession = Depends(get_db), user: dict = Depends(get
])
@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"}