Initial commit

This commit is contained in:
2026-05-17 05:22:06 +03:00
commit ca4d00c895
155 changed files with 45216 additions and 0 deletions
View File
+92
View File
@@ -0,0 +1,92 @@
import time
import secrets
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from passlib.context import CryptContext
from app.models.models import User, LoginAttempt, AuditLog
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
BRUTE_FORCE_WINDOW = 180
BRUTE_FORCE_MAX_ATTEMPTS = 3
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(password: str, hashed: str) -> bool:
return pwd_context.verify(password, hashed)
def generate_csrf_token() -> str:
return secrets.token_hex(32)
async def brute_force_check(db: AsyncSession, login: str, ip_address: str) -> bool:
cutoff = datetime.utcnow().timestamp() - BRUTE_FORCE_WINDOW
result = await db.execute(
select(func.count(LoginAttempt.id))
.where(
(LoginAttempt.login == login) | (LoginAttempt.ip_address == ip_address),
LoginAttempt.attempt_time > datetime.fromtimestamp(cutoff),
)
)
count = result.scalar() or 0
return count >= BRUTE_FORCE_MAX_ATTEMPTS
async def record_attempt(db: AsyncSession, login: str, ip_address: str):
attempt = LoginAttempt(login=login, ip_address=ip_address)
db.add(attempt)
await db.flush()
async def clear_attempts(db: AsyncSession, login: str, ip_address: str):
from sqlalchemy import delete
await db.execute(
delete(LoginAttempt).where(
(LoginAttempt.login == login) | (LoginAttempt.ip_address == ip_address)
)
)
await db.flush()
async def authenticate(db: AsyncSession, login: str, password: str, ip_address: str) -> dict:
if await brute_force_check(db, login, ip_address):
return {"success": False, "error": "Превышен лимит попыток. Подождите 3 минуты."}
result = await db.execute(
select(User).where(User.login == login).limit(1)
)
user = result.scalar_one_or_none()
if not user or not verify_password(password, user.password_hash):
await record_attempt(db, login, ip_address)
return {"success": False, "error": "Неверный логин или пароль."}
if not user.is_active:
return {"success": False, "error": "Учётная запись заблокирована."}
await clear_attempts(db, login, ip_address)
await audit_log(db, user.id, "login", "Вход в систему", ip_address)
return {
"success": True,
"user": {
"id": user.id,
"login": user.login,
"role": user.role,
"full_name": user.full_name,
},
}
async def audit_log(db: AsyncSession, user_id: int | None, action: str, details: str, ip_address: str):
try:
log = AuditLog(user_id=user_id, action=action, details=details, ip_address=ip_address)
db.add(log)
await db.flush()
except Exception:
pass
+499
View File
@@ -0,0 +1,499 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.models import FormulaCoefficient
from functools import lru_cache
import math
_coeff_cache: dict[str, float] = {}
async def get_coefficient(db: AsyncSession, key: str, default: float = 0.0) -> float:
global _coeff_cache
if not _coeff_cache:
result = await db.execute(
select(FormulaCoefficient.key, FormulaCoefficient.value)
.where(FormulaCoefficient.is_active == True)
)
_coeff_cache = {row.key: float(row.value) for row in result.all()}
return _coeff_cache.get(key, default)
async def reload_coefficients(db: AsyncSession):
global _coeff_cache
_coeff_cache = {}
await get_coefficient(db, "dummy")
def fmt_money(amount: float) -> str:
return f"{amount:,.0f}".replace(",", " ") + ""
def fmt_percent(value: float) -> str:
return f"{value:.1f}%"
def fmt_hours(hours: float) -> str:
return f"{hours:.1f} ч"
def badge_class(status: str, badge_type: str = "default") -> str:
mapping = {
"P1": "badge-critical", "P2": "badge-high", "P3": "badge-medium", "P4": "badge-low",
"open": "badge-open", "in_progress": "badge-progress", "completed": "badge-done", "cancelled": "badge-cancelled",
"active": "badge-active", "inactive": "badge-inactive", "draft": "badge-draft", "final": "badge-done",
"Risk": "badge-risk", "Crisis": "badge-crisis", "Stable": "badge-stable", "Growth": "badge-growth",
}
return mapping.get(status, "badge-default")
async def calc_risk_score(db: AsyncSession, no_archive: bool, no_power_backup: bool, no_regulations: bool, system_failures: bool, no_documentation: bool) -> float:
return (
(await get_coefficient(db, "risk_score.no_archive", 25) if no_archive else 0)
+ (await get_coefficient(db, "risk_score.no_power_backup", 20) if no_power_backup else 0)
+ (await get_coefficient(db, "risk_score.no_regulations", 15) if no_regulations else 0)
+ (await get_coefficient(db, "risk_score.system_failures", 20) if system_failures else 0)
+ (await get_coefficient(db, "risk_score.no_documentation", 10) if no_documentation else 0)
)
async def risk_label(db: AsyncSession, score: float) -> str:
if score <= await get_coefficient(db, "risk_score.bound_low", 20):
return "Низкий"
if score <= await get_coefficient(db, "risk_score.bound_medium", 50):
return "Средний"
if score <= await get_coefficient(db, "risk_score.bound_high", 75):
return "Высокий"
return "Критический"
async def risk_multiplier(db: AsyncSession, score: float) -> float:
if score <= await get_coefficient(db, "risk_score.bound_low", 20):
return await get_coefficient(db, "risk_multiplier.low", 1.0)
if score <= await get_coefficient(db, "risk_score.bound_medium", 50):
return await get_coefficient(db, "risk_multiplier.medium", 1.3)
if score <= await get_coefficient(db, "risk_score.bound_high", 75):
return await get_coefficient(db, "risk_multiplier.high", 1.6)
return await get_coefficient(db, "risk_multiplier.critical", 2.0)
async def calc_complexity(db: AsyncSession, cameras: int, access_points: int, fire_type: str, has_it: bool) -> float:
if cameras <= 20:
video = await get_coefficient(db, "complexity.video_0_20", 10)
elif cameras <= 100:
video = await get_coefficient(db, "complexity.video_20_100", 20)
else:
video = await get_coefficient(db, "complexity.video_100plus", 35)
if access_points <= 5:
access = await get_coefficient(db, "complexity.access_0_5", 10)
elif access_points <= 20:
access = await get_coefficient(db, "complexity.access_5_20", 20)
else:
access = await get_coefficient(db, "complexity.access_20plus", 30)
if fire_type == "simple":
fire = await get_coefficient(db, "complexity.fire_simple", 15)
elif fire_type == "medium":
fire = await get_coefficient(db, "complexity.fire_medium", 25)
elif fire_type == "complex":
fire = await get_coefficient(db, "complexity.fire_complex", 40)
else:
fire = 0
it = await get_coefficient(db, "complexity.it", 10) if has_it else 0
return video + access + fire + it
def complexity_label(score: float) -> str:
if score <= 30:
return "Простой"
if score <= 70:
return "Средний"
if score <= 120:
return "Сложный"
return "Enterprise"
async def calc_infrastructure_load(db: AsyncSession, server_state: str, network_state: str, power_state: str) -> float:
if server_state == "none":
server = await get_coefficient(db, "infra.server_none", 15)
elif server_state == "weak":
server = await get_coefficient(db, "infra.server_weak", 10)
else:
server = 0
if network_state == "unstable":
network = await get_coefficient(db, "infra.network_unstable", 20)
elif network_state == "partial":
network = await get_coefficient(db, "infra.network_partial", 10)
else:
network = 0
if power_state == "none":
power = await get_coefficient(db, "infra.power_none", 20)
elif power_state == "weak":
power = await get_coefficient(db, "infra.power_weak", 10)
else:
power = 0
return server + network + power
async def calc_service_history(db: AsyncSession, service_state: str) -> float:
mapping = {
"none": "history.none",
"irregular": "history.irregular",
"formal": "history.formal",
"sla": "history.sla",
}
key = mapping.get(service_state, "history.none")
return await get_coefficient(db, key, 30)
async def calc_object_index(db: AsyncSession, risk: float, complexity: float, infra: float, history: float) -> float:
return round(
risk * await get_coefficient(db, "object_index.risk_weight", 0.4)
+ complexity * await get_coefficient(db, "object_index.complexity_weight", 0.3)
+ infra * await get_coefficient(db, "object_index.infra_weight", 0.2)
+ history * await get_coefficient(db, "object_index.history_weight", 0.1),
2,
)
def object_class(index: float) -> str:
if index <= 30:
return "A"
if index <= 60:
return "B"
if index <= 90:
return "C"
return "D"
def object_class_label(index: float) -> str:
cls = object_class(index)
labels = {
"A": "Лёгкий SLA",
"B": "Стандарт SLA",
"C": "Сложный SLA",
"D": "Enterprise / Высокий риск",
}
return labels.get(cls, "")
def calc_sla_price(base_cost: float, object_index: float, region_factor: float, risk_mult: float) -> float:
return round(base_cost * object_index * region_factor * risk_mult, 2)
async def calc_engineer_score(db: AsyncSession, sla_compliance: float, response_time_score: float, resolution_time_score: float, diagnosis_accuracy: float, reopen_rate_score: float, risk_coverage_score: float) -> float:
return round(
(await get_coefficient(db, "engineer.score.sla_weight", 0.25) * sla_compliance)
+ (await get_coefficient(db, "engineer.score.response_weight", 0.20) * response_time_score)
+ (await get_coefficient(db, "engineer.score.resolution_weight", 0.20) * resolution_time_score)
+ (await get_coefficient(db, "engineer.score.diagnosis_weight", 0.15) * diagnosis_accuracy)
+ (await get_coefficient(db, "engineer.score.reopen_weight", 0.10) * reopen_rate_score)
+ (await get_coefficient(db, "engineer.score.risk_coverage_weight", 0.10) * risk_coverage_score),
2,
)
async def engineer_grade(db: AsyncSession, score: float) -> str:
if score >= await get_coefficient(db, "engineer.grade.senior", 90):
return "Senior"
if score >= await get_coefficient(db, "engineer.grade.strong", 80):
return "Strong"
if score >= await get_coefficient(db, "engineer.grade.middle", 70):
return "Middle"
return "Junior"
async def calc_engineer_control_score(db: AsyncSession, sla_control: float, task_dist: float, incident_red: float, team_perf: float, response_coord: float) -> float:
return round(
(await get_coefficient(db, "ecs.sla_weight", 0.30) * sla_control)
+ (await get_coefficient(db, "ecs.task_dist_weight", 0.25) * task_dist)
+ (await get_coefficient(db, "ecs.incident_red_weight", 0.20) * incident_red)
+ (await get_coefficient(db, "ecs.team_perf_weight", 0.15) * team_perf)
+ (await get_coefficient(db, "ecs.response_coord_weight", 0.10) * response_coord),
2,
)
async def calc_shs(db: AsyncSession, sla_stability: float, revenue_stability: float, retention: float, engineer_performance: float, incident_stability: float, sales_flow: float, operational_efficiency: float) -> float:
return round(
(await get_coefficient(db, "shs.sla_stability_weight", 0.22) * sla_stability)
+ (await get_coefficient(db, "shs.revenue_stability_weight", 0.18) * revenue_stability)
+ (await get_coefficient(db, "shs.retention_weight", 0.18) * retention)
+ (await get_coefficient(db, "shs.engineer_perf_weight", 0.15) * engineer_performance)
+ (await get_coefficient(db, "shs.incident_stability_weight", 0.12) * incident_stability)
+ (await get_coefficient(db, "shs.sales_flow_weight", 0.10) * sales_flow)
+ (await get_coefficient(db, "shs.operational_eff_weight", 0.05) * operational_efficiency),
2,
)
async def calc_ceo_shs(db: AsyncSession, mrr_growth: float, sla_compliance: float, retention: float, productivity: float, conversion: float, incident_stability: float) -> float:
return round(
(await get_coefficient(db, "ceo_shs.mrr_weight", 0.25) * mrr_growth)
+ (await get_coefficient(db, "ceo_shs.sla_weight", 0.20) * sla_compliance)
+ (await get_coefficient(db, "ceo_shs.retention_weight", 0.20) * retention)
+ (await get_coefficient(db, "ceo_shs.productivity_weight", 0.15) * productivity)
+ (await get_coefficient(db, "ceo_shs.conversion_weight", 0.10) * conversion)
+ (await get_coefficient(db, "ceo_shs.incident_weight", 0.10) * incident_stability),
2,
)
async def shs_status(db: AsyncSession, score: float) -> str:
if score >= await get_coefficient(db, "shs.zone_growth", 85):
return "Growth"
if score >= await get_coefficient(db, "shs.zone_stable", 70):
return "Stable"
if score >= await get_coefficient(db, "shs.zone_risk", 50):
return "Risk"
return "Crisis"
async def shs_zone(db: AsyncSession, score: float) -> dict:
if score >= await get_coefficient(db, "shs.zone_growth", 85):
return {"zone": "green", "label": "Growth", "action": ""}
if score >= await get_coefficient(db, "shs.zone_stable", 70):
return {"zone": "yellow", "label": "Stable", "action": "Мониторинг"}
if score >= await get_coefficient(db, "shs.zone_risk", 50):
return {"zone": "yellow", "label": "Risk", "action": "Снизить продажи, запустить аудит цикла, пересмотреть назначения"}
return {"zone": "red", "label": "Crisis", "action": "Немедленный аудит, приостановка новых продаж, пересмотр команды"}
async def shs_delta(db: AsyncSession, current_score: float, previous_score: float | None) -> dict | None:
if previous_score is None:
return None
delta = current_score - previous_score
threshold_warn = await get_coefficient(db, "shs.delta_warning", 5)
threshold_crit = await get_coefficient(db, "shs.delta_critical", 10)
if delta <= -threshold_crit:
return {"delta": round(delta, 1), "level": "critical", "message": f"SHS упал более чем на {threshold_crit} пунктов — кризис"}
if delta <= -threshold_warn:
return {"delta": round(delta, 1), "level": "warning", "message": f"SHS упал более чем на {threshold_warn} пунктов — требуется внимание"}
return {"delta": round(delta, 1), "level": "ok", "message": ""}
async def calc_sla_stability_index(db: AsyncSession, sla_compliance: float, breach_severity_weighted: float) -> float:
ssi = sla_compliance * (1 - breach_severity_weighted)
return round(max(0, min(100, ssi)), 2)
async def calc_breach_severity(db: AsyncSession, breaches_p1: int, breaches_p2: int, breaches_p3: int) -> float:
total = breaches_p1 + breaches_p2 + breaches_p3
if total == 0:
return 0
weighted = (
breaches_p1 * await get_coefficient(db, "ssi.breach_severity_p1", 1.0)
+ breaches_p2 * await get_coefficient(db, "ssi.breach_severity_p2", 0.5)
+ breaches_p3 * await get_coefficient(db, "ssi.breach_severity_p3", 0.2)
)
return min(1, weighted / total)
def calc_revenue_stability_index(mrr_history: list[float]) -> float:
count = len(mrr_history)
if count < 2:
return 100
mean = sum(mrr_history) / count
if mean <= 0:
return 100
variance = sum((mrr - mean) ** 2 for mrr in mrr_history) / count
std_dev = math.sqrt(variance)
cv = std_dev / mean
return round(max(0, min(100, 100 * (1 - cv))), 2)
def calc_sales_flow_index(audits: int, sla_contracts: int, lead_quality: float = 1.0) -> float:
if audits <= 0:
return 0
conversion = sla_contracts / audits
return round(min(100, conversion * 100 * lead_quality), 2)
def calc_operational_efficiency_index(revenue: float, engineer_hours: float, cost_per_hour: float) -> float:
total = engineer_hours * cost_per_hour
if total <= 0:
return 100
return round(min(100, (revenue / total) * 100), 2)
async def calc_incident_stability_index(db: AsyncSession, incidents_p1: int, incidents_p2: int, incidents_p3: int, object_count: int) -> float:
if object_count <= 0:
return 100
weighted = (
incidents_p1 * await get_coefficient(db, "isi.severity_p1", 1.0)
+ incidents_p2 * await get_coefficient(db, "isi.severity_p2", 0.5)
+ incidents_p3 * await get_coefficient(db, "isi.severity_p3", 0.2)
)
rate = weighted / object_count
return round(max(0, min(100, 100 - (rate * 20))), 2)
async def check_automation_rules(db: AsyncSession, shs: float | None, sla_compliance: float | None, retention: float | None) -> list[dict]:
alerts = []
if shs is not None and shs < await get_coefficient(db, "rules.shs_threshold", 70):
alerts.append({
"rule": "SHS",
"severity": "critical",
"message": f"SHS ниже {await get_coefficient(db, 'rules.shs_threshold', 70)} — снизить продажи, запустить аудит, пересмотреть назначения инженеров.",
})
if sla_compliance is not None and sla_compliance < await get_coefficient(db, "rules.sla_threshold", 90):
alerts.append({
"rule": "SLA",
"severity": "warning",
"message": f"SLA Compliance ниже {await get_coefficient(db, 'rules.sla_threshold', 90)}% — заморозить неприоритетные проекты, увеличить частоту проверок.",
})
if retention is not None and retention < await get_coefficient(db, "rules.retention_threshold", 90):
alerts.append({
"rule": "Retention",
"severity": "critical",
"message": f"Удержание клиентов ниже {await get_coefficient(db, 'rules.retention_threshold', 90)}% — обязательный аудит клиентов, пересмотр назначений инженеров.",
})
return alerts
def calc_sla_compliance(closed_in_sla: int, total: int) -> float:
if total <= 0:
return 100
return round((closed_in_sla / total) * 100, 2)
def calc_reopen_rate(reopened: int, total: int) -> float:
if total <= 0:
return 0
return round((reopened / total) * 100, 2)
def calc_diagnosis_accuracy(confirmed: int, found: int) -> float:
if found <= 0:
return 100
return round((confirmed / found) * 100, 2)
def calc_response_time_score(actual_hours: float, sla_hours: float) -> float:
if sla_hours <= 0:
return 100
ratio = actual_hours / sla_hours
if ratio <= 1.0:
return 100
if ratio <= 1.2:
return 80
return max(0, 100 - (ratio - 1.2) * 50)
def calc_resolution_time_score(actual_hours: float, norm_hours: float) -> float:
if norm_hours <= 0:
return 100
ratio = actual_hours / norm_hours
if ratio <= 1.0:
return 100
if ratio <= 1.5:
return 70
return max(0, 100 - (ratio - 1.5) * 40)
def calc_risk_coverage(checked_zones: int, total_zones: int = 5) -> float:
if total_zones <= 0:
return 0
return round((checked_zones / total_zones) * 100, 2)
def calc_documentation_quality(full_reports: int, total_visits: int) -> float:
if total_visits <= 0:
return 100
return round((full_reports / total_visits) * 100, 2)
def calc_tlb(tasks_per_engineer: list[float]) -> float:
count = len(tasks_per_engineer)
if count < 2:
return 0
mean = sum(tasks_per_engineer) / count
variance = sum((t - mean) ** 2 for t in tasks_per_engineer) / count
return round(math.sqrt(variance), 2)
def calc_mrr(total_sla_annual: float) -> float:
return round(total_sla_annual / 12, 2)
def calc_rpe(sla_revenue: float, engineer_count: int) -> float:
if engineer_count <= 0:
return 0
return round(sla_revenue / engineer_count, 2)
def calc_cpo(total_cost: float, object_count: int) -> float:
if object_count <= 0:
return 0
return round(total_cost / object_count, 2)
def calc_arpc(total_revenue: float, client_count: int) -> float:
if client_count <= 0:
return 0
return round(total_revenue / client_count, 2)
def calc_utilization(working_hours: int, available_hours: int) -> float:
if available_hours <= 0:
return 0
return round((working_hours / available_hours) * 100, 2)
def calc_autonomy(independent: int, total: int) -> float:
if total <= 0:
return 100
return round((independent / total) * 100, 2)
def calc_escalation_rate(escalated: int, total: int) -> float:
if total <= 0:
return 0
return round((escalated / total) * 100, 2)
def calc_uptime(uptime_hours: float, total_hours: float) -> float:
if total_hours <= 0:
return 100
return round((uptime_hours / total_hours) * 100, 2)
def calc_retention(active_clients: int, total_clients: int) -> float:
if total_clients <= 0:
return 100
return round((active_clients / total_clients) * 100, 2)
async def calc_retention_key_client(db: AsyncSession, base_retention: float, lost_key_clients: int) -> float:
penalty = lost_key_clients * await get_coefficient(db, "retention.key_client_penalty", 0.1)
return round(max(0, base_retention - penalty * 100), 2)
def calc_csat(positive: int, total: int) -> float:
if total <= 0:
return 100
return round((positive / total) * 100, 2)
def calc_incident_rate(incidents: int, object_count: int) -> float:
if object_count <= 0:
return 0
return round(incidents / object_count, 2)
def calc_incident_reduction(previous: int, current: int) -> float:
if previous <= 0:
return 0
return round(((previous - current) / previous) * 100, 2)
async def calc_bonus_percent(db: AsyncSession, engineer_score: float) -> float:
if engineer_score >= await get_coefficient(db, "bonus.score_90plus", 90):
return await get_coefficient(db, "bonus.premium_90plus", 20)
if engineer_score >= await get_coefficient(db, "bonus.score_80_89", 80):
return await get_coefficient(db, "bonus.premium_80_89", 10)
return await get_coefficient(db, "bonus.premium_below_80", 0)
+24
View File
@@ -0,0 +1,24 @@
import os
from pathlib import Path
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://aegisone:aegisone_pass@localhost:5432/aegisone"
SECRET_KEY: str = "change-me-to-random-string"
SESSION_TTL: int = 3600
APP_ENV: str = "development"
LOG_LEVEL: str = "debug"
DOCS_DIR: str = str(Path(__file__).parent.parent.parent / "docs")
PERMISSIONS_DIR: str = str(Path(__file__).parent.parent.parent / "permissions")
UPLOADS_DIR: str = str(Path(__file__).parent.parent.parent / "uploads")
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
@lru_cache()
def get_settings() -> Settings:
return Settings()
+24
View File
@@ -0,0 +1,24 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.config import get_settings
settings = get_settings()
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.APP_ENV == "development",
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db() -> AsyncSession:
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
+187
View File
@@ -0,0 +1,187 @@
import os
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.services.docs_permissions import discover_docs
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
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.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"
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/"]
):
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)
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("/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
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(int)))
.where(Task.closed_at.isnot(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")
return templates.TemplateResponse("dashboard.html", {
"request": request, "stats": stats, "shs_alert": shs_alert, "now": now, "user": user,
})
@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("/health")
async def health():
return {"status": "ok"}
View File
+465
View File
@@ -0,0 +1,465 @@
from datetime import datetime
from sqlalchemy import (
Column, Integer, String, Boolean, Text, DateTime, Numeric, Date, BigInteger, SmallInteger, JSON, ForeignKey, CheckConstraint
)
from sqlalchemy.orm import relationship, declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
login = Column(String(50), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
role = Column(String(20), nullable=False, default="technician")
full_name = Column(String(255), nullable=False)
phone = Column(String(50), default="")
email = Column(String(255), default="")
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("role IN ('owner','engineer','technician')", name="ck_users_role"),
)
objects_created = relationship("Object", foreign_keys="Object.created_by", back_populates="creator")
tasks_created = relationship("Task", foreign_keys="Task.created_by", back_populates="creator")
tasks_assigned = relationship("Task", foreign_keys="Task.assigned_to", back_populates="assignee")
class LoginAttempt(Base):
__tablename__ = "login_attempts"
id = Column(Integer, primary_key=True, autoincrement=True)
ip_address = Column(String(45), nullable=False)
login = Column(String(50), nullable=False, default="")
attempt_time = Column(DateTime, nullable=False, server_default="NOW()")
class AuditLog(Base):
__tablename__ = "audit_log"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
action = Column(String(255), nullable=False)
details = Column(Text)
ip_address = Column(String(45), default="")
created_at = Column(DateTime, nullable=False, server_default="NOW()")
class Customer(Base):
__tablename__ = "customers"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False)
inn = Column(String(12), default="")
kpp = Column(String(9), default="")
legal_address = Column(String(500), default="")
contact_person = Column(String(255), default="")
contact_phone = Column(String(50), default="")
contact_email = Column(String(255), default="")
status = Column(String(20), nullable=False, default="active")
notes = Column(Text)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("status IN ('active','inactive','prospect')", name="ck_customers_status"),
)
objects = relationship("Object", back_populates="customer")
sla_contracts = relationship("SLAContract", back_populates="customer")
class Object(Base):
__tablename__ = "objects"
id = Column(Integer, primary_key=True, autoincrement=True)
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"))
name = Column(String(255), nullable=False)
address = Column(String(500), default="")
object_type = Column(String(100), default="")
area_sqm = Column(Numeric(10, 2), default=0)
employees_count = Column(Integer, default=0)
contact_person = Column(String(255), default="")
contact_phone = Column(String(50), default="")
status = Column(String(20), nullable=False, default="active")
notes = Column(Text)
risk_score = Column(Numeric(5, 2), default=0)
complexity_index = Column(Numeric(5, 2), default=0)
infrastructure_load = Column(Numeric(5, 2), default=0)
service_history = Column(Numeric(5, 2), default=0)
object_index = Column(Numeric(5, 2), default=0)
sla_price_monthly = Column(Numeric(12, 2), default=0)
region_factor = Column(Numeric(3, 2), default=1.00)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("status IN ('active','inactive','audit','prospective')", name="ck_objects_status"),
)
customer = relationship("Customer", back_populates="objects")
creator = relationship("User", foreign_keys=[created_by], back_populates="objects_created")
assignments = relationship("ObjectAssignment", back_populates="object")
tasks = relationship("Task", back_populates="object")
reports = relationship("Report", back_populates="object")
incidents = relationship("Incident", back_populates="object")
sla_contracts = relationship("SLAContract", back_populates="object")
passports = relationship("ObjectPassport", back_populates="object")
class ObjectAssignment(Base):
__tablename__ = "object_assignments"
id = Column(Integer, primary_key=True, autoincrement=True)
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
role = Column(String(20), nullable=False)
assigned_at = Column(DateTime, nullable=False, server_default="NOW()")
unassigned_at = Column(DateTime)
__table_args__ = (
CheckConstraint("role IN ('engineer','technician')", name="ck_assignments_role"),
)
object = relationship("Object", back_populates="assignments")
user = relationship("User")
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, autoincrement=True)
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
assigned_to = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
title = Column(String(500), nullable=False)
description = Column(Text)
priority = Column(String(4), nullable=False, default="P3")
status = Column(String(20), nullable=False, default="open")
deadline = Column(DateTime)
sla_remaining_hours = Column(Numeric(6, 1), default=0)
response_time_minutes = Column(Integer, default=0)
resolution_time_minutes = Column(Integer, default=0)
sla_compliant = Column(Boolean)
checklist_data = Column(JSON)
result_notes = Column(Text)
closed_at = Column(DateTime)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("priority IN ('P1','P2','P3','P4')", name="ck_tasks_priority"),
CheckConstraint("status IN ('open','in_progress','completed','cancelled')", name="ck_tasks_status"),
)
object = relationship("Object", back_populates="tasks")
creator = relationship("User", foreign_keys=[created_by], back_populates="tasks_created")
assignee = relationship("User", foreign_keys=[assigned_to], back_populates="tasks_assigned")
comments = relationship("TaskComment", back_populates="task")
photos = relationship("TaskPhoto", back_populates="task")
class TaskComment(Base):
__tablename__ = "task_comments"
id = Column(Integer, primary_key=True, autoincrement=True)
task_id = Column(Integer, ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
comment = Column(Text, nullable=False)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
task = relationship("Task", back_populates="comments")
user = relationship("User")
class TaskPhoto(Base):
__tablename__ = "task_photos"
id = Column(Integer, primary_key=True, autoincrement=True)
task_id = Column(Integer, ForeignKey("tasks.id", ondelete="SET NULL"))
report_id = Column(Integer, ForeignKey("reports.id", ondelete="SET NULL"))
file_path = Column(String(500), nullable=False)
description = Column(String(500), default="")
type = Column(String(20), default="other")
uploaded_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_at = Column(DateTime, nullable=False, server_default="NOW()")
__table_args__ = (
CheckConstraint("type IN ('before','after','evidence','other')", name="ck_photos_type"),
)
task = relationship("Task", back_populates="photos")
uploader = relationship("User")
class Report(Base):
__tablename__ = "reports"
id = Column(Integer, primary_key=True, autoincrement=True)
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
report_type = Column(String(20), nullable=False, default="service")
title = Column(String(500), default="")
description = Column(Text)
findings = Column(Text)
recommendations = Column(Text)
work_done = Column(Text)
cause_classification = Column(String(100))
result_status = Column(String(20), default="ok")
status = Column(String(20), nullable=False, default="draft")
client_confirmed = Column(Boolean, default=False)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("report_type IN ('inspection','service','emergency','audit','monthly')", name="ck_reports_type"),
CheckConstraint("result_status IN ('fixed','partial','revisit','ok')", name="ck_reports_result"),
CheckConstraint("status IN ('draft','final','cancelled')", name="ck_reports_status"),
)
object = relationship("Object", back_populates="reports")
creator = relationship("User")
class QuestionnaireSession(Base):
__tablename__ = "questionnaire_sessions"
id = Column(Integer, primary_key=True, autoincrement=True)
object_id = Column(Integer, ForeignKey("objects.id", ondelete="SET NULL"))
client_name = Column(String(255), default="")
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
status = Column(String(20), nullable=False, default="draft")
current_step = Column(SmallInteger, nullable=False, default=1)
risk_score = Column(Numeric(5, 2), default=0)
complexity_index = Column(Numeric(5, 2), default=0)
infrastructure_load = Column(Numeric(5, 2), default=0)
service_history = Column(Numeric(5, 2), default=0)
object_index = Column(Numeric(5, 2), default=0)
sla_price_monthly = Column(Numeric(12, 2), default=0)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("status IN ('draft','completed','cancelled')", name="ck_qsessions_status"),
)
answers = relationship("QuestionnaireAnswer", back_populates="session")
passport = relationship("ObjectPassport", back_populates="session")
class QuestionnaireAnswer(Base):
__tablename__ = "questionnaire_answers"
id = Column(Integer, primary_key=True, autoincrement=True)
session_id = Column(Integer, ForeignKey("questionnaire_sessions.id", ondelete="CASCADE"), nullable=False)
step = Column(SmallInteger, nullable=False)
question_key = Column(String(100), nullable=False)
answer_value = Column(Text)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
session = relationship("QuestionnaireSession", back_populates="answers")
class QuestionnaireItem(Base):
__tablename__ = "questionnaire_items"
id = Column(Integer, primary_key=True, autoincrement=True)
step = Column(Integer, nullable=False, default=1)
section = Column(String(100), nullable=False, default="")
question_key = Column(String(100), unique=True, nullable=False)
label = Column(String(255), nullable=False)
type = Column(String(20), nullable=False, default="text")
options = Column(JSON)
required = Column(Boolean, nullable=False, default=False)
sort_order = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, nullable=False, default=True)
help_text = Column(String(500), default="")
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("type IN ('text','number','select','radio','checkbox','textarea')", name="ck_qitems_type"),
)
class ObjectPassport(Base):
__tablename__ = "object_passports"
id = Column(Integer, primary_key=True, autoincrement=True)
session_id = Column(Integer, ForeignKey("questionnaire_sessions.id", ondelete="SET NULL"))
object_id = Column(Integer, ForeignKey("objects.id", ondelete="SET NULL"))
object_index = Column(Numeric(5, 2), default=0)
risk_score = Column(Numeric(5, 2), default=0)
complexity_index = Column(Numeric(5, 2), default=0)
sla_price_monthly = Column(Numeric(12, 2), default=0)
passport_data = Column(JSON)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_at = Column(DateTime, nullable=False, server_default="NOW()")
session = relationship("QuestionnaireSession", back_populates="passport")
object = relationship("Object", back_populates="passports")
creator = relationship("User")
class SLAContract(Base):
__tablename__ = "sla_contracts"
id = Column(Integer, primary_key=True, autoincrement=True)
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"))
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
contract_number = Column(String(50), default="")
client_name = Column(String(255), default="")
description = Column(Text)
start_date = Column(Date, nullable=False)
end_date = Column(Date)
sla_price_monthly = Column(Numeric(12, 2), nullable=False, default=0)
penalty_rate = Column(Numeric(5, 2), default=0)
response_time_p1 = Column(Integer, default=4)
response_time_p2 = Column(Integer, default=8)
response_time_p3 = Column(Integer, default=24)
response_time_hours = Column(Numeric(5, 1))
service_level = Column(String(20), nullable=False, default="business")
status = Column(String(20), nullable=False, default="negotiation")
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("service_level IN ('start','business','enterprise')", name="ck_sla_level"),
CheckConstraint("status IN ('active','expired','cancelled','negotiation')", name="ck_sla_status"),
)
customer = relationship("Customer", back_populates="sla_contracts")
object = relationship("Object", back_populates="sla_contracts")
creator = relationship("User")
class Incident(Base):
__tablename__ = "incidents"
id = Column(Integer, primary_key=True, autoincrement=True)
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
reported_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
assigned_to = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
title = Column(String(500), nullable=False)
description = Column(Text)
severity = Column(String(4), nullable=False, default="P3")
status = Column(String(20), nullable=False, default="open")
resolution = Column(Text)
cause = Column(String(500), default="")
resolution_time_minutes = Column(Integer, default=0)
sla_breached = Column(Boolean, default=False)
resolved_at = Column(DateTime)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("severity IN ('P1','P2','P3')", name="ck_incidents_severity"),
CheckConstraint("status IN ('open','in_progress','resolved','closed')", name="ck_incidents_status"),
)
object = relationship("Object", back_populates="incidents")
reporter = relationship("User", foreign_keys=[reported_by])
assignee = relationship("User", foreign_keys=[assigned_to])
class EngineerKPI(Base):
__tablename__ = "engineer_kpi"
id = Column(Integer, primary_key=True, autoincrement=True)
engineer_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
period_start = Column(Date, nullable=False)
period_end = Column(Date, nullable=False)
sla_compliance = Column(Numeric(5, 2), default=0)
response_time_score = Column(Numeric(5, 2), default=0)
resolution_time_score = Column(Numeric(5, 2), default=0)
diagnosis_accuracy = Column(Numeric(5, 2), default=0)
reopen_rate = Column(Numeric(5, 2), default=0)
risk_coverage_score = Column(Numeric(5, 2), default=0)
documentation_quality = Column(Numeric(5, 2), default=0)
engineer_score = Column(Numeric(5, 2), default=0)
grade = Column(String(20), default="")
components_json = Column(JSON)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
engineer = relationship("User")
class SHSRecord(Base):
__tablename__ = "shs_records"
id = Column(Integer, primary_key=True, autoincrement=True)
recorded_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
period_start = Column(Date, nullable=False)
period_end = Column(Date, nullable=False)
sla_stability = Column(Numeric(5, 2), default=0)
revenue_stability = Column(Numeric(5, 2), default=0)
retention = Column(Numeric(5, 2), default=0)
engineer_performance = Column(Numeric(5, 2), default=0)
incident_stability = Column(Numeric(5, 2), default=0)
sales_flow = Column(Numeric(5, 2), default=0)
operational_efficiency = Column(Numeric(5, 2), default=0)
shs_score = Column(Numeric(5, 2), default=0)
shs_status = Column(String(20), default="")
components_json = Column(JSON)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
recorder = relationship("User")
class BlogPost(Base):
__tablename__ = "blog_posts"
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(500), nullable=False)
slug = Column(String(500), unique=True, nullable=False)
content = Column(Text, nullable=False)
excerpt = Column(String(1000), default="")
category = Column(String(50), nullable=False, default="audit")
author_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
status = Column(String(20), nullable=False, default="draft")
published_at = Column(DateTime)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("category IN ('audit','sla','incident','supervision','documentation','risk','cases')", name="ck_blog_category"),
CheckConstraint("status IN ('draft','published','archived')", name="ck_blog_status"),
)
author = relationship("User")
class FormulaCoefficient(Base):
__tablename__ = "formula_coefficients"
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(100), unique=True, nullable=False)
value = Column(Numeric(10, 4), nullable=False, default=0)
description = Column(String(500), default="")
formula_ref = Column(String(255), default="")
is_active = Column(Boolean, nullable=False, default=True)
updated_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
updater = relationship("User")
class Case(Base):
__tablename__ = "cases"
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(500), nullable=False)
text = Column(Text, nullable=False)
effect = Column(Text, nullable=False)
sort_order = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
View File
View File
View File
@@ -0,0 +1,65 @@
import os
import json
from pathlib import Path
from app.config import get_settings
settings = get_settings()
PERMISSIONS_FILE = os.path.join(settings.PERMISSIONS_DIR, "docs_permissions.json")
def load_permissions() -> dict:
if os.path.exists(PERMISSIONS_FILE):
with open(PERMISSIONS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
return {}
def save_permissions(perms: dict):
os.makedirs(settings.PERMISSIONS_DIR, exist_ok=True)
with open(PERMISSIONS_FILE, "w", encoding="utf-8") as f:
json.dump(perms, f, indent=2, ensure_ascii=False)
def discover_docs() -> dict:
docs_dir = settings.DOCS_DIR
if not os.path.exists(docs_dir):
return {}
perms = load_permissions()
existing_slugs = set(perms.keys())
for filename in os.listdir(docs_dir):
if filename.endswith(".md"):
slug = Path(filename).stem
if slug not in existing_slugs:
title = slug.replace("-", " ").title()
perms[slug] = {
"title": title,
"filename": filename,
"sort_order": len(perms),
"permissions": {
"engineer": {"view": False, "edit": False, "cancel": False}
},
}
save_permissions(perms)
return perms
def get_allowed_docs(role: str) -> list[dict]:
perms = discover_docs()
if role == "owner":
return sorted(perms.values(), key=lambda x: x.get("sort_order", 0))
if role == "engineer":
return [
doc for doc in perms.values()
if doc.get("permissions", {}).get("engineer", {}).get("view", False)
]
return []
def can_access_doc(role: str, slug: str, action: str = "view") -> bool:
if role == "owner":
return True
if role == "engineer":
perms = load_permissions()
doc = perms.get(slug, {})
return doc.get("permissions", {}).get("engineer", {}).get(action, False)
return False
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}Service Portal — AegisOne Engineering{% endblock %}</title>
<link rel="stylesheet" href="/service-style.php">
<link rel="icon" type="image/x-icon" href="/assets/img/favicon.ico">
{% block extra_head %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
{% block content %}{% endblock %}
{% block extra_scripts %}{% endblock %}
</body>
</html>
+126
View File
@@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block body_class %}dashboard-page{% endblock %}
{% block content %}
<button class="sidebar-toggle" onclick="document.querySelector('.sidebar').classList.toggle('open')"></button>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">Aegis<span>One</span></div>
<div style="font-size:10px;color:var(--text-muted);">Service Portal</div>
</div>
<nav class="sidebar-nav">
<div class="sidebar-section">Панель</div>
<a href="/service/dashboard" class="active">Дашборд</a>
{% if user.role == 'owner' %}
<div class="sidebar-section">Управление</div>
<a href="/service/users">Сотрудники</a>
<a href="/service/customers">Клиенты</a>
<a href="/service/objects">Объекты</a>
<a href="/service/assignments">Назначения</a>
<a href="/service/sla">SLA контракты</a>
<a href="/service/ceo">CEO дашборд</a>
<a href="/service/coefficients">Коэффициенты</a>
{% elif user.role == 'engineer' %}
<div class="sidebar-section">Управление</div>
<a href="/service/customers">Клиенты</a>
<a href="/service/objects">Объекты</a>
<a href="/service/sla">SLA контракты</a>
{% endif %}
<div class="sidebar-section">Работа</div>
{% if user.role in ['engineer', 'technician'] %}
<a href="/service/tasks">Задачи</a>
<a href="/service/reports">Отчёты</a>
<a href="/service/incidents">Инциденты</a>
{% endif %}
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/questionnaire">Опросник</a>
{% endif %}
{% if user.role == 'owner' %}
<a href="/service/questionnaire-config">Настройка опросника</a>
{% endif %}
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/passports">Паспорта объектов</a>
{% endif %}
{% if user.role == 'technician' %}
<a href="/service/checklist">Чек-лист</a>
{% endif %}
<div class="sidebar-section">Блог</div>
{% if user.role == 'owner' %}
<a href="/service/blog">Управление блогом</a>
{% endif %}
<a href="/blog/">Публичный блог</a>
<div class="sidebar-section">Контент</div>
{% if user.role == 'owner' %}
<a href="/service/cases">Примеры из практики</a>
{% endif %}
<div class="sidebar-section">Документация</div>
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/docs">Просмотр документации</a>
{% endif %}
{% if user.role == 'owner' %}
<a href="/service/docs-manage">Управление документами</a>
{% endif %}
</nav>
<div class="sidebar-footer">
<div class="user-name">{{ user.full_name }}</div>
<div class="user-role">{{ {'owner': 'Владелец', 'engineer': 'Инженер', 'technician': 'Техник'}[user.role] }}</div>
<a href="/service/logout" style="display:inline-block;margin-top:6px;font-size:12px;">Выйти</a>
</div>
</aside>
<main class="main">
<div class="main-header">
<div>
<h1>Дашборд</h1>
<div class="breadcrumb"><a href="/service/dashboard">Главная</a></div>
</div>
<div class="actions">
<span style="font-size:12px;color:var(--text-muted);">{{ now }}</span>
</div>
</div>
{% if shs_alert %}
<div class="shs-alert critical">⚠ {{ shs_alert }}</div>
{% endif %}
<div class="card-grid">
<a href="/service/objects" class="card card-link">
<div class="card-label">Активные объекты</div>
<div class="card-value value-accent">{{ stats.objects }}</div>
</a>
<a href="/service/tasks" class="card card-link">
<div class="card-label">Активные задачи</div>
<div class="card-value {% if stats.tasks > 0 %}value-yellow{% else %}value-green{% endif %}">{{ stats.tasks }}</div>
</a>
<a href="/service/incidents" class="card card-link">
<div class="card-label">Открытые инциденты</div>
<div class="card-value {% if stats.incidents > 0 %}value-red{% else %}value-green{% endif %}">{{ stats.incidents }}</div>
</a>
<a href="/service/sla" class="card card-link">
<div class="card-label">SLA контракты</div>
<div class="card-value value-info">{{ stats.sla_count }}</div>
</a>
<a href="/service/sla" class="card card-link">
<div class="card-label">SLA Compliance</div>
<div class="card-value {% if stats.sla_compliance >= 90 %}value-green{% elif stats.sla_compliance >= 70 %}value-yellow{% else %}value-red{% endif %}">{{ stats.sla_compliance }}%</div>
</a>
{% if stats.shs %}
<a href="/service/ceo" class="card card-link">
<div class="card-label">SHS (System Health)</div>
<div class="card-value {% if stats.shs.shs_status in ['Growth', 'Stable'] %}value-green{% else %}value-red{% endif %}">{{ stats.shs.shs_score }}</div>
<div class="card-label"><span class="badge badge-{{ stats.shs.shs_status|lower }}">{{ stats.shs.shs_status }}</span></div>
</a>
{% endif %}
{% if stats.my_tasks is defined %}
<a href="/service/tasks" class="card card-link">
<div class="card-label">Мои задачи</div>
<div class="card-value">{{ stats.my_tasks }}</div>
</a>
{% endif %}
</div>
</main>
</div>
<style>
.card-link { text-decoration:none; color:inherit; display:block; transition:transform 0.2s, box-shadow 0.2s; }
.card-link:hover { transform:translateY(-2px); box-shadow:var(--shadow); cursor:pointer; }
</style>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block body_class %}login-page{% endblock %}
{% block content %}
<div class="login-box">
<div class="login-brand">
<img src="/logo.php" alt="AegisOne" class="login-brand__img">
<div class="login-brand__title">AegisOne Engineering</div>
<div class="login-brand__subtitle">Service Portal</div>
</div>
<div class="login-divider"></div>
<h1>Вход в сервисный портал</h1>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post">
<div class="form-group">
<label for="login">Логин</label>
<input type="text" id="login" name="login" placeholder="Введите логин" required autocomplete="username" autofocus>
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input type="password" id="password" name="password" placeholder="Введите пароль" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary btn-block">Войти</button>
</form>
<p class="login-box__footnote">* Сервисный портал для сотрудников AegisOne Engineering</p>
</div>
{% endblock %}