diff --git a/py_service/CHANGELOG.md b/py_service/CHANGELOG.md new file mode 100644 index 0000000..5695508 --- /dev/null +++ b/py_service/CHANGELOG.md @@ -0,0 +1,120 @@ +# Changelog — AegisOne Service Portal + +## 1.4.0 (20.05.2026) + +## 1.3.0 (20.05.2026) +### Новые функции +- П.49: Подключены favicon (ico, png, webmanifest, apple-touch-icon) + лого на странице логина +- П.44: Сохранение scroll сайдбара при навигации (sessionStorage) +- П.47: Липкий тулбар редактора документов (верх/низ) +- П.33: Кастомный модал подтверждения выхода из опросника (вместо системного confirm) +- П.33: Добавлен POST /service/questionnaire/save/{step} — сохранение черновика +### Исправления и улучшения +- П.35: Версия ПО отображается на всех страницах, не только дашборде +- П.40: Звёзды избранного выровнены в одну строку с пунктами меню +- П.42: discover_docs() вызывается при каждом открытии документа — новые .md сразу видны +- П.43: POST /admin/permissions использует discover_docs вместо load_permissions +- П.45: Футер сайдбара: имя → выход → версия +- П.46: Иконки EasyMDE видны на тёмной теме ([data-theme="dark"] overrides) +- П.48: Создание "Пример из практики" — добавлен action="/service/cases/create" +- П.29: SLA — action="/service/sla/create" у формы + safe type conversions в edit +- П.31: Идеи — защита от None description ((idea.description or '')[:100]) +- П.49: Назначения — a.object_name → a.object.name, a.user_name → a.user.full_name +- П.26: Заголовок "Назначения" → "Назначение сотрудников" +- П.24: Объекты — список клиентов в select (c.id/c.name вместо c[0]/c[1]) +- П.24: Объекты — editObject защита от None customer_id + safe int() в backend +- П.23: Валидация клиентов: * у обязательных полей, KPP (9 цифр), маска телефона "8 (___) ___-__-__" +- П.36: Portainer URL https://81.177.141.34:9443 + +## 1.2.1 (20.05.2026) +### Новые функции +- П.38: Стилизация логина — "One" цветом #00ADEF +- П.45: Футер сайдбара: имя → выход → версия +- П.44: Сохранение scroll сайдбара при навигации (sessionStorage) +- П.47: Липкий тулбар редактора документов +- П.49: Подключены favicon (ico, png, webmanifest, apple-touch-icon) +### Исправления и улучшения +- П.35: Версия ПО отображается на всех страницах, не только дашборде +- П.40: Звёзды избранного выровнены в одну строку с пунктами меню +- П.42: discover_docs() вызывается при каждом открытии документа — новые .md сразу видны +- П.43: Управление документами — POST использует discover_docs вместо load_permissions +- П.46: Иконки EasyMDE видны на тёмной теме +- П.48: Создание "Пример из практики" — добавлен action="/service/cases/create" +- П.29: SLA — добавлен action="/service/sla/create" у формы, safe type conversions +- П.31: Идеи — защита от None description (|default('')) +- П.49: Назначения — a.object_name → a.object.name, a.user_name → a.user.full_name +- П.26: Заголовок "Назначения" → "Назначение сотрудников" +- П.24: Объекты — список клиентов в select (c.id/c.name вместо c[0]/c[1]) +- П.36: Portainer URL через https://81.177.141.34:9443 +- П.33: Опросник — добавлен POST /service/questionnaire/save/{step}, кастомный модал + +## 1.2.0 (20.05.2026) +### Доработки +- П.21: Кнопка "Настроить" на дашборде — выбор отображаемых показателей (localStorage) +- П.22: Исправлена ошибка Internal Server Error при редактировании пользователя (добавлен action у формы) +- П.23: Исправлена ошибка Method Not Allowed при создании клиента; ИНН обязателен (10/12 цифр) +- П.24: Тип объекта переведён на русский (select вместо input) +- П.25: Исправлена ошибка Method Not Allowed при создании/редактировании объекта +- П.26: "Назначения" → "Назначение сотрудников" +- П.27: Статус SLA по умолчанию — "Переговоры" (negotiation) +- П.28: Уровни SLA отображаются на русском: Базовый/Оптимальный/Максимальный +- П.29: Проверена работа создания и редактирования SLA контрактов +- П.30: Расширены графики: группировка (день/неделя/месяц), название, сохранение/экспорт PNG +- П.31: Исправлена ошибка Internal Server Error при смене статуса идеи +- П.32: Быстрое меню заменено на звёздочки (★) — избранное в localStorage +- П.33: Кнопка "Выйти" в опроснике с подтверждением сохранения черновика +- П.34: Убрана надпись "Главная" под каждым меню +- П.35: Версия показывается полностью только на дашборде, в остальных — "v" +- П.36: Ссылка Portainer изменена на IP:порт (через PORTAINER_URL) +- П.37: Документация: левое меню (page.html), авто-загрузка .md из /py_service/docs + +## 1.1.0 (18.05.2026) +### Баги +- discover_docs: миграция существующих permissions без technician — добавляется technician: {view: false} + engineer: {edit: false, cancel: false} +- favicon: исправлен тип (image/x-icon → image/svg+xml) и расширение (favicon.ico → favicon.svg) +### Доработки +- П.15: Визуальная система формул — коэффициенты сгруппированы по formula_ref в аккордеоны +- П.15: Информация о формуле (📐) — модалка с текстом формулы и описанием результата +- П.15: Песочница формул (/service/coefficients/test) — подстановка параметров и расчёт для 7 формул: + risk_score, complexity, object_index, engineer, ecs, shs, ceo_shs +- П.15: Иконка 🧪 у каждой группы ведёт в песочницу с предвыбором группы +- П.15: В сайдбар добавлена ссылка "Тест формул" (owner) +- CSS: добавлены стили аккордеона и панели теста формул + +## 1.0.3 (18.05.2026) +### Баги +- П.18: Ченджлог не открывался — CHANGELOG.md исключался .dockerignore (*.md убран) +- П.20: Ошибка при сохранении пользователя — исправлена логика is_active (без дефолта), phone/email не сбрасываются +### Доработки +- П.12: Авто-добавление документов из /py_service/docs через discover_docs() в lifespan +- П.13: Владелец редактирует .md файлы через /service/documents/{slug}/edit (EasyMDE) +- П.14: Система прав документов: + - Владелец видит/создаёт/редактирует/удаляет все документы + - Владелец назначает engineer'у просмотр/редактирование через /service/documents/admin/permissions + - Engineer видит только назначенные документы + - Engineer даёт technician'у просмотр своих документов через /service/documents/tech-access + - Technician видит только те документы, что открыл engineer +- П.15: Удаление коэффициентов (кнопка ✕); предложен редизайн с группировкой по formula_ref +- П.16: Гибкие графики для владельца (/service/charts) — Chart.js: задачи/SHS/KPI/MRR/объекты, типы: bar/line/pie/doughnut +- П.17: "Service Portal" увеличен с 10px до 12px +- П.19: Роль убрана из футера сайдбара, "Выйти" под ФИО +- Удалены дублирующие ручки /service/docs* (старая система), везде /service/documents/ + +## 1.0.2 (18.05.2026) +### Доработки +- П.3: Логотип AegisOne в sidebare (sidebar-logo-text, 2rem) +- П.5: Версия кликабельна — модалка с историей изменений (changelog) +- П.6: Текст логотипа увеличен (sidebar-logo-text, 2rem) +- П.7: Верхнее меню упрощено — только логотип и кнопка темы +- П.8: Быстрое меню — настройка своих и техниковских ссылок (owner/engineer) +- П.9: Система идей — CRUD + статусы (normal/in_progress/completed) +- П.11: Ссылки на Portainer и Gitea в левом меню (owner) + +## 1.0.1 (18.05.2026) + +## 1.0.0 +### Новые функции +- Первый запуск сервисного портала на FastAPI +- Базовые страницы: логин, дашборд, документация +- Авторизация (owner/engineer/technician) +- Дашборд с основной статистикой (объекты, задачи, инциденты, SLA, SHS) diff --git a/py_service/app/__pycache__/auth.cpython-38.pyc b/py_service/app/__pycache__/auth.cpython-38.pyc new file mode 100644 index 0000000..d50a770 Binary files /dev/null and b/py_service/app/__pycache__/auth.cpython-38.pyc differ diff --git a/py_service/app/__pycache__/config.cpython-38.pyc b/py_service/app/__pycache__/config.cpython-38.pyc new file mode 100644 index 0000000..e29c035 Binary files /dev/null and b/py_service/app/__pycache__/config.cpython-38.pyc differ diff --git a/py_service/app/__pycache__/documents_router.cpython-38.pyc b/py_service/app/__pycache__/documents_router.cpython-38.pyc new file mode 100644 index 0000000..a8156b0 Binary files /dev/null and b/py_service/app/__pycache__/documents_router.cpython-38.pyc differ diff --git a/py_service/app/auth.py b/py_service/app/auth.py index 0067996..7555e1b 100644 --- a/py_service/app/auth.py +++ b/py_service/app/auth.py @@ -1,23 +1,21 @@ import time import secrets +import bcrypt 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) + return bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt()).decode() def verify_password(password: str, hashed: str) -> bool: - return pwd_context.verify(password, hashed) + return bcrypt.checkpw(password.encode()[:72], hashed.encode()) def generate_csrf_token() -> str: diff --git a/py_service/app/config.py b/py_service/app/config.py index 95be857..c77065a 100644 --- a/py_service/app/config.py +++ b/py_service/app/config.py @@ -10,9 +10,10 @@ class Settings(BaseSettings): SESSION_TTL: int = 3600 APP_ENV: str = "development" LOG_LEVEL: str = "debug" - DOCS_DIR: str = str(Path(__file__).parent.parent.parent / "docs") + DOCS_DIR: str = str(Path(__file__).parent.parent / "docs") PERMISSIONS_DIR: str = str(Path(__file__).parent.parent.parent / "permissions") UPLOADS_DIR: str = str(Path(__file__).parent.parent.parent / "uploads") + PORTAINER_URL: str = "https://81.177.141.34:9443" class Config: env_file = ".env" diff --git a/py_service/app/dependencies.py b/py_service/app/dependencies.py new file mode 100644 index 0000000..51a37a2 --- /dev/null +++ b/py_service/app/dependencies.py @@ -0,0 +1,16 @@ +from fastapi import Request, Depends +from fastapi.responses import HTMLResponse, RedirectResponse + + +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 diff --git a/py_service/app/documents_router.py b/py_service/app/documents_router.py new file mode 100644 index 0000000..5e9e8fa --- /dev/null +++ b/py_service/app/documents_router.py @@ -0,0 +1,282 @@ +import os +import re +import markdown +from pathlib import Path +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.config import get_settings +from app.services.docs_permissions import load_permissions, save_permissions, discover_docs, can_access_doc, get_allowed_docs +from app.dependencies import get_current_user, require_role + +settings = get_settings() +router = APIRouter(prefix="/service/documents", tags=["documents"]) +from app.templating import templates + + +def transliterate_slug(text: str) -> str: + translit_map = { + 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', 'ж': 'zh', + 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n', 'о': 'o', + 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', 'ф': 'f', 'х': 'kh', 'ц': 'ts', + 'ч': 'ch', 'ш': 'sh', 'щ': 'shch', 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', + 'я': 'ya', ' ': '_', + } + result = ''.join(translit_map.get(c.lower(), c) for c in text) + result = re.sub(r'[^a-zA-Z0-9_\-]', '', result) + result = re.sub(r'_+', '_', result) + return result.strip('_').lower() + + +def render_markdown(content: str) -> str: + md = markdown.Markdown(extensions=['tables', 'fenced_code', 'toc', 'attr_list']) + return md.convert(content) + + +@router.get("/") +async def documents_list( + request: Request, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + docs = get_allowed_docs(user["role"]) + return templates.TemplateResponse("documents/list.html", { + "request": request, "user": user, "docs": docs, + }) + + +@router.get("/{slug}") +async def document_view( + slug: str, + request: Request, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + if not can_access_doc(user["role"], slug, "view"): + return HTMLResponse(content="Доступ запрещён", status_code=403) + + docs_dir = settings.DOCS_DIR + perms = discover_docs() + doc = perms.get(slug) + if not doc: + return HTMLResponse(content="Документ не найден", status_code=404) + + filepath = os.path.join(docs_dir, doc["filename"]) + if not os.path.exists(filepath): + return HTMLResponse(content="Файл не найден", status_code=404) + + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + + html_content = render_markdown(content) + return templates.TemplateResponse("documents/view.html", { + "request": request, "user": user, "doc": doc, "content": html_content, + "can_edit": can_access_doc(user["role"], slug, "edit"), + "can_download": user["role"] == "owner", + }) + + +@router.get("/{slug}/edit") +async def document_edit( + slug: str, + request: Request, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + if not can_access_doc(user["role"], slug, "edit"): + return HTMLResponse(content="Доступ запрещён", status_code=403) + + docs_dir = settings.DOCS_DIR + perms = discover_docs() + doc = perms.get(slug) + if not doc: + return HTMLResponse(content="Документ не найден", status_code=404) + + filepath = os.path.join(docs_dir, doc["filename"]) + if not os.path.exists(filepath): + return HTMLResponse(content="Файл не найден", status_code=404) + + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + + return templates.TemplateResponse("documents/edit.html", { + "request": request, "user": user, "doc": doc, "content": content, + }) + + +@router.post("/{slug}/edit") +async def document_update( + slug: str, + request: Request, + content: str = Form(...), + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + if not can_access_doc(user["role"], slug, "edit"): + return HTMLResponse(content="Доступ запрещён", status_code=403) + + docs_dir = settings.DOCS_DIR + perms = discover_docs() + doc = perms.get(slug) + if not doc: + return HTMLResponse(content="Документ не найден", status_code=404) + + filepath = os.path.join(docs_dir, doc["filename"]) + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + + return RedirectResponse(url=f"/service/documents/{slug}", status_code=302) + + +@router.get("/tech-access") +async def tech_access_page( + request: Request, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + if user["role"] != "engineer": + return HTMLResponse(content="Доступ только для инженеров", status_code=403) + + perms = discover_docs() + editable_docs = [ + doc for slug, doc in perms.items() + if doc.get("permissions", {}).get("engineer", {}).get("edit", False) + ] + return templates.TemplateResponse("documents/tech_access.html", { + "request": request, "user": user, "docs": editable_docs, "perms": perms, + }) + + +@router.post("/tech-access") +async def tech_access_update( + request: Request, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + if user["role"] != "engineer": + return HTMLResponse(content="Доступ только для инженеров", status_code=403) + + form = await request.form() + perms = discover_docs() + + for slug in perms.keys(): + if not perms[slug].get("permissions", {}).get("engineer", {}).get("edit", False): + continue + technician_view = form.get(f"{slug}_technician_view") == "on" + perms[slug]["permissions"]["technician"] = {"view": technician_view} + + save_permissions(perms) + return RedirectResponse(url="/service/documents/tech-access", status_code=302) + + +@router.get("/{slug}/download") +async def document_download( + slug: str, + request: Request, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + if user["role"] != "owner": + return HTMLResponse(content="Скачивание доступно только владельцу", status_code=403) + + from fastapi.responses import FileResponse + docs_dir = settings.DOCS_DIR + perms = discover_docs() + doc = perms.get(slug) + if not doc: + return HTMLResponse(content="Документ не найден", status_code=404) + + filepath = os.path.join(docs_dir, doc["filename"]) + if not os.path.exists(filepath): + return HTMLResponse(content="Файл не найден", status_code=404) + + return FileResponse(filepath, filename=doc["filename"], media_type="text/markdown") + + +@router.get("/admin/permissions") +async def permissions_page( + request: Request, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + if user["role"] != "owner": + return HTMLResponse(content="Доступ запрещён", status_code=403) + + perms = discover_docs() + return templates.TemplateResponse("documents/permissions.html", { + "request": request, "user": user, "perms": perms, + }) + + +@router.post("/admin/permissions") +async def permissions_update( + request: Request, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + if user["role"] != "owner": + return HTMLResponse(content="Доступ запрещён", status_code=403) + + form = await request.form() + perms = discover_docs() + + for slug in perms.keys(): + engineer_view = form.get(f"{slug}_engineer_view") == "on" + engineer_edit = form.get(f"{slug}_engineer_edit") == "on" + technician_view = form.get(f"{slug}_technician_view") == "on" + + if engineer_edit and not engineer_view: + engineer_view = True + + perms[slug]["permissions"]["engineer"] = { + "view": engineer_view, + "edit": engineer_edit, + "cancel": engineer_edit, + } + perms[slug]["permissions"]["technician"] = { + "view": technician_view, + } + + save_permissions(perms) + return RedirectResponse(url="/service/documents/admin/permissions", status_code=302) + + +@router.post("/admin/create") +async def document_create( + request: Request, + title: str = Form(...), + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + if user["role"] != "owner": + return HTMLResponse(content="Доступ запрещён", status_code=403) + + slug = transliterate_slug(title) + if not slug: + return HTMLResponse(content="Некорректное название", status_code=400) + + docs_dir = settings.DOCS_DIR + os.makedirs(docs_dir, exist_ok=True) + filename = f"{slug}.md" + filepath = os.path.join(docs_dir, filename) + + if os.path.exists(filepath): + return HTMLResponse(content="Документ уже существует", status_code=400) + + with open(filepath, "w", encoding="utf-8") as f: + f.write(f"# {title}\n\n") + + perms = load_permissions() + perms[slug] = { + "title": title, + "filename": filename, + "sort_order": len(perms), + "permissions": { + "engineer": {"view": False, "edit": False, "cancel": False}, + "technician": {"view": False}, + }, + } + save_permissions(perms) + + return RedirectResponse(url=f"/service/documents/{slug}/edit", status_code=302) diff --git a/py_service/app/main.py b/py_service/app/main.py index 054663c..26c0fa0 100644 --- a/py_service/app/main.py +++ b/py_service/app/main.py @@ -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": "

Файл изменений не найден.

"}) + 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"} diff --git a/py_service/app/models/models.py b/py_service/app/models/models.py index fd3c9db..495825e 100644 --- a/py_service/app/models/models.py +++ b/py_service/app/models/models.py @@ -463,3 +463,35 @@ class Case(Base): 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()") + + +class Idea(Base): + __tablename__ = "ideas" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(500), nullable=False) + description = Column(Text, default="") + status = Column(String(20), nullable=False, default="normal") + 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 ('normal','in_progress','completed')", name="ck_ideas_status"), + ) + + creator = relationship("User") + + +class QuickMenuItem(Base): + __tablename__ = "quick_menu_items" + + id = Column(Integer, primary_key=True, autoincrement=True) + role = Column(String(20), nullable=False) + label = Column(String(100), nullable=False) + url = Column(String(500), nullable=False) + sort_order = Column(Integer, nullable=False, default=0) + created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) + updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()") + + creator = relationship("User") diff --git a/py_service/app/routers/__pycache__/service_pages.cpython-38.pyc b/py_service/app/routers/__pycache__/service_pages.cpython-38.pyc new file mode 100644 index 0000000..9327319 Binary files /dev/null and b/py_service/app/routers/__pycache__/service_pages.cpython-38.pyc differ diff --git a/py_service/app/routers/service_pages.py b/py_service/app/routers/service_pages.py new file mode 100644 index 0000000..2d83e4f --- /dev/null +++ b/py_service/app/routers/service_pages.py @@ -0,0 +1,1081 @@ +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 +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): + c = {"request": request, "user": user, "now": now_str()} + 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() + from app.auth import hash_password + new_user = User( + login=form.get("login"), + password_hash=hash_password(form.get("password")), + full_name=form.get("full_name"), role=form.get("role", "technician"), + phone=form.get("phone", ""), email=form.get("email", ""), + is_active=form.get("is_active", "1") == "1", + ) + db.add(new_user); await db.flush() + await audit_log(db, user["id"], "user_create", f"Создан пользователь {form.get('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() + u = await db.get(User, int(form.get("id"))) + 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) + u.is_active = form.get("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"))): + u = await db.get(User, int((await request.form()).get("id"))) + 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.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"))): + 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"))): + 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) + + + diff --git a/py_service/app/services/__pycache__/docs_permissions.cpython-38.pyc b/py_service/app/services/__pycache__/docs_permissions.cpython-38.pyc new file mode 100644 index 0000000..8b844ca Binary files /dev/null and b/py_service/app/services/__pycache__/docs_permissions.cpython-38.pyc differ diff --git a/py_service/app/services/docs_permissions.py b/py_service/app/services/docs_permissions.py index c8a46dc..7eb2f3a 100644 --- a/py_service/app/services/docs_permissions.py +++ b/py_service/app/services/docs_permissions.py @@ -36,10 +36,29 @@ def discover_docs() -> dict: "filename": filename, "sort_order": len(perms), "permissions": { - "engineer": {"view": False, "edit": False, "cancel": False} + "engineer": {"view": False, "edit": False, "cancel": False}, + "technician": {"view": False}, }, } - save_permissions(perms) + # migrate existing entries missing technician key + changed = False + for slug, doc in perms.items(): + if "permissions" not in doc: + doc["permissions"] = {} + perms_doc = doc["permissions"] + if "technician" not in perms_doc: + perms_doc["technician"] = {"view": False} + changed = True + # ensure engineer has edit/cancel defaults + eng = perms_doc.setdefault("engineer", {}) + if "edit" not in eng: + eng["edit"] = False + changed = True + if "cancel" not in eng: + eng["cancel"] = False + changed = True + if changed: + save_permissions(perms) return perms @@ -52,14 +71,21 @@ def get_allowed_docs(role: str) -> list[dict]: doc for doc in perms.values() if doc.get("permissions", {}).get("engineer", {}).get("view", False) ] + if role == "technician": + return [ + doc for doc in perms.values() + if doc.get("permissions", {}).get("technician", {}).get("view", False) + ] return [] def can_access_doc(role: str, slug: str, action: str = "view") -> bool: if role == "owner": return True + perms = load_permissions() + doc = perms.get(slug, {}) if role == "engineer": - perms = load_permissions() - doc = perms.get(slug, {}) return doc.get("permissions", {}).get("engineer", {}).get(action, False) + if role == "technician": + return doc.get("permissions", {}).get("technician", {}).get("view", False) return False diff --git a/py_service/app/static/css/service-theme.css b/py_service/app/static/css/service-theme.css new file mode 100644 index 0000000..ce9912a --- /dev/null +++ b/py_service/app/static/css/service-theme.css @@ -0,0 +1,168 @@ +@charset "UTF-8"; + +:root { + --bg-body: #0D1B2A; + --bg-card: #1B2838; + --bg-card-hover: #1e3044; + --bg-sidebar: #0A1628; + --bg-input: #1e2a3e; + --border: #253248; + --border-focus: #00ADEF; + --text-primary: #E8EDF2; + --text-secondary: #A0B4C8; + --text-muted: #6B8299; + --accent: #00ADEF; + --accent-hover: #33c4f5; + --accent-dim: rgba(0, 173, 239, 0.15); + --success: #22c55e; + --warning: #f59e0b; + --danger: #ef4444; + --info: #6366f1; + --font: 'Inter', 'Segoe UI', sans-serif; + --radius: 10px; + --shadow: 0 2px 12px rgba(0,0,0,0.4); +} + +[data-theme="light"] { + --bg-body: #F5F7FA; + --bg-card: #FFFFFF; + --bg-card-hover: #f0f4f8; + --bg-sidebar: #FFFFFF; + --bg-input: #FFFFFF; + --border: #e2e8f0; + --border-focus: #00ADEF; + --text-primary: #1a1a2e; + --text-secondary: #5a6a7a; + --text-muted: #8899aa; + --accent: #00ADEF; + --accent-hover: #0095cc; + --accent-dim: rgba(0, 173, 239, 0.1); + --success: #16a34a; + --warning: #d97706; + --danger: #dc2626; + --info: #4f46e5; + --shadow: 0 2px 12px rgba(10, 42, 77, 0.1); +} + +[data-theme="dark"] { + --bg-body: #0D1B2A; + --bg-card: #1B2838; + --bg-card-hover: #1e3044; + --bg-sidebar: #0A1628; + --bg-input: #1e2a3e; + --border: #253248; + --border-focus: #00ADEF; + --text-primary: #E8EDF2; + --text-secondary: #A0B4C8; + --text-muted: #6B8299; + --accent: #00ADEF; + --accent-hover: #33c4f5; + --accent-dim: rgba(0, 173, 239, 0.15); + --success: #22c55e; + --warning: #f59e0b; + --danger: #ef4444; + --info: #6366f1; + --shadow: 0 2px 12px rgba(0,0,0,0.4); +} + +body { + background: var(--bg-body); + color: var(--text-primary); + transition: background 0.3s, color 0.3s; +} + +.theme-switcher { + display: flex; + align-items: center; + gap: 6px; +} + +.theme-switcher button { + background: var(--accent-dim); + border: 1px solid var(--border); + border-radius: 6px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 16px; + color: var(--text-secondary); + transition: all 0.2s; +} + +.theme-switcher button:hover { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.theme-label { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; +} + +/* EasyMDE dark theme overrides */ +[data-theme="dark"] .EasyMDEContainer .editor-toolbar { + background: var(--bg-card); + border-color: var(--border); +} +[data-theme="dark"] .EasyMDEContainer .CodeMirror { + background: var(--bg-input); + color: var(--text-primary); + border-color: var(--border); +} +[data-theme="dark"] .editor-toolbar button { + color: var(--text-secondary); + border-color: transparent; +} +[data-theme="dark"] .editor-toolbar button:hover { + background: var(--accent-dim); + border-color: var(--accent); + color: var(--text-primary); +} +[data-theme="dark"] .editor-toolbar button.active { + background: var(--accent-dim); + border-color: var(--accent); + color: var(--accent); +} +[data-theme="dark"] .CodeMirror-cursor { + border-color: var(--accent) !important; +} +[data-theme="dark"] .CodeMirror-selected { + background: var(--accent-dim) !important; +} +[data-theme="dark"] .editor-preview { + background: var(--bg-card); + color: var(--text-primary); +} +[data-theme="dark"] .editor-preview-side { + background: var(--bg-card); + color: var(--text-primary); + border-color: var(--border); +} +[data-theme="dark"] .editor-statusbar { + color: var(--text-muted); +} + +/* Sticky editor toolbar */ +.EasyMDEContainer .editor-toolbar { + position: sticky; + top: 0; + z-index: 10; + background: var(--bg-card); + border-radius: var(--radius) var(--radius) 0 0; +} + +/* Sticky bottom actions on edit page */ +.edit-form-actions { + position: sticky; + bottom: 0; + z-index: 10; + background: var(--bg-card); + padding: 12px 0; + border-top: 1px solid var(--border); + margin-top: 8px; +} diff --git a/py_service/app/static/css/service.css b/py_service/app/static/css/service.css new file mode 100644 index 0000000..8f16812 --- /dev/null +++ b/py_service/app/static/css/service.css @@ -0,0 +1,1234 @@ +@charset "UTF-8"; +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Montserrat:wght@600;700;800&display=swap'); + +:root { + --bg-body: #0a0e1a; + --bg-card: #131a2e; + --bg-card-hover: #1a2340; + --bg-sidebar: #0d1322; + --bg-input: #1a2340; + --border: #1e2a4a; + --border-focus: #00ADEF; + --text-primary: #e8edf5; + --text-secondary: #8899bb; + --text-muted: #556688; + --accent: #00ADEF; + --accent-hover: #33c4f5; + --accent-dim: rgba(0, 173, 239, 0.15); + --success: #22c55e; + --warning: #f59e0b; + --danger: #ef4444; + --info: #6366f1; + --font: 'Inter', 'Segoe UI', sans-serif; + --radius: 10px; + --shadow: 0 2px 12px rgba(0,0,0,0.3); +} + +/* ---------- SCROLLBARS ---------- */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: rgba(255,255,255,0.03); border-radius: 3px; } +::-webkit-scrollbar-thumb { background: rgba(0,173,239,0.3); border-radius: 3px; transition: background 0.2s; } +::-webkit-scrollbar-thumb:hover { background: rgba(0,173,239,0.5); } +* { scrollbar-width: thin; scrollbar-color: rgba(0,173,239,0.3) rgba(255,255,255,0.03); } + +* { margin:0; padding:0; box-sizing:border-box; } + +html, body { + font-family: var(--font); + background: var(--bg-body); + color: var(--text-primary); + font-size: 14px; + line-height: 1.5; + min-height: 100vh; +} + +a { color: var(--accent); text-decoration:none; } +a:hover { color: var(--accent-hover); } +img { max-width:100%; height:auto; } + +/* ---------- LOGIN PAGE ---------- */ +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: linear-gradient(135deg, #0a0e1a 0%, #0d1a30 50%, #0a0e1a 100%); +} + +.login-box { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 36px 32px; + width: 100%; + max-width: 400px; + box-shadow: var(--shadow); +} + +.login-brand { + text-align: center; + margin-bottom: 24px; +} + +.login-brand__img { + display: block; + height: 56px; + margin: 0 auto 16px; +} + +.login-brand__title { + font-family: 'Montserrat', sans-serif; + font-weight: 800; + font-size: 24px; + color: var(--text-primary); + letter-spacing: -0.02em; + line-height: 1.1; +} + +.login-brand__subtitle { + font-family: 'Inter', sans-serif; + font-weight: 500; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.2em; + color: var(--accent); + margin-top: 4px; +} + +.login-divider { + border: none; + border-top: 1px solid var(--border); + margin: 20px 0; +} + +.login-box h1 { + font-size: 20px; + font-weight: 700; + margin-bottom: 24px; + color: var(--text-primary); + text-align: center; +} + +.login-box__footnote { + margin-top: 24px; + font-size: 11px; + color: var(--text-muted); + text-align: center; + line-height: 1.5; +} + +.form-group { + margin-bottom: 18px; +} + +.form-group label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 10px 14px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-family: var(--font); + font-size: 14px; + transition: border 0.2s; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--border-focus); + box-shadow: 0 0 0 3px var(--accent-dim); +} + +.form-group textarea { + min-height: 80px; + resize: vertical; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 20px; + border: none; + border-radius: 6px; + font-family: var(--font); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.btn-primary { + background: var(--accent); + color: #fff; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-secondary { + background: var(--bg-input); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + border-color: var(--accent); +} + +.btn-success { + background: var(--success); + color: #fff; +} + +.btn-danger { + background: var(--danger); + color: #fff; +} + +.btn-warning { + background: var(--warning); + color: #fff; +} + +.btn-sm { + padding: 5px 12px; + font-size: 12px; +} + +.btn-block { + width: 100%; + justify-content: center; +} + +.alert { + padding: 12px 16px; + border-radius: 6px; + font-size: 13px; + margin-bottom: 16px; +} + +.alert-error { + background: rgba(239, 68, 68, 0.15); + color: var(--danger); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.alert-success { + background: rgba(34, 197, 94, 0.15); + color: var(--success); + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.alert-warning { + background: rgba(245, 158, 11, 0.15); + color: var(--warning); + border: 1px solid rgba(245, 158, 11, 0.3); +} + +.alert-info { + background: rgba(99, 102, 241, 0.15); + color: var(--info); + border: 1px solid rgba(99, 102, 241, 0.3); +} + +/* ---------- LAYOUT ---------- */ +.layout { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: 240px; + background: var(--bg-sidebar); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + height: 100vh; + z-index: 100; + transition: transform 0.3s; +} + +.sidebar-header { + padding: 20px; + border-bottom: 1px solid var(--border); +} + +.sidebar-header .logo { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); +} + +.sidebar-header .logo span { + color: var(--accent); +} + +.sidebar-nav { + flex: 1; + padding: 12px 0; + overflow-y: auto; +} + +.sidebar-nav a { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + transition: all 0.2s; +} + +.sidebar-nav a:hover, +.sidebar-nav a.active { + color: var(--text-primary); + background: var(--accent-dim); +} + +.sidebar-nav a.active { + border-right: 3px solid var(--accent); +} + +.sidebar-section { + padding: 8px 20px 4px; + font-size: 10px; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; +} + +.sidebar-footer { + padding: 16px 20px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.sidebar-logo-text { font-size: 2rem !important; line-height: 1.1; } + +.version-pill { + display: inline-block; + margin-top: 4px; + padding: 2px 10px; + background: var(--accent-dim); + color: var(--accent); + border: 1px solid rgba(0,173,239,0.2); + border-radius: 12px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} +.version-pill:hover { + background: rgba(0,173,239,0.25); + border-color: var(--accent); +} + +.status-completed { color: var(--success); font-weight: 600; } + +.sidebar-footer .user-name { + color: var(--text-primary); + font-weight: 600; +} + +.sidebar-footer .user-role { + color: var(--text-muted); + font-size: 11px; +} + +.sidebar-toggle { + display: none; + position: fixed; + top: 12px; + left: 12px; + z-index: 101; + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--text-primary); + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 18px; +} + +.main { + flex: 1; + margin-left: 240px; + padding: 24px; + min-height: 100vh; +} + +.main-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.main-header h1 { + font-size: 24px; + font-weight: 700; +} + +.main-header .breadcrumb { + font-size: 12px; + color: var(--text-muted); +} + +.main-header .breadcrumb a { + color: var(--text-secondary); +} + +.main-header .breadcrumb a:hover { + color: var(--accent); +} + +/* ---------- CARDS ---------- */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 16px; + transition: box-shadow 0.2s; +} + +.card:hover { + box-shadow: var(--shadow); +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 14px; +} + +.card-header h2, +.card-header h3 { + font-size: 15px; + font-weight: 600; +} + +.card-value { + font-size: 28px; + font-weight: 700; +} + +.card-label { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +/* ---------- KPI VALUES ---------- */ +.value-green { color: var(--success); } +.value-yellow { color: var(--warning); } +.value-red { color: var(--danger); } +.value-accent { color: var(--accent); } +.value-info { color: var(--info); } + +/* ---------- TABLES ---------- */ +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +table th { + text-align: left; + padding: 10px 12px; + font-size: 11px; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +table td { + padding: 10px 12px; + font-size: 13px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +table tr:hover td { + background: rgba(0, 173, 239, 0.03); +} + +table .actions { + white-space: nowrap; + text-align: right; +} + +table .actions a, +table .actions button { + margin-left: 6px; +} + +/* ---------- BADGES ---------- */ +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +} + +.badge-critical { background: rgba(239,68,68,0.2); color: var(--danger); } +.badge-high { background: rgba(245,158,11,0.2); color: var(--warning); } +.badge-medium { background: rgba(99,102,241,0.2); color: var(--info); } +.badge-low { background: rgba(136,153,187,0.2); color: var(--text-secondary); } +.badge-open { background: rgba(99,102,241,0.2); color: var(--info); } +.badge-progress { background: rgba(245,158,11,0.2); color: var(--warning); } +.badge-done { background: rgba(34,197,94,0.2); color: var(--success); } +.badge-cancelled { background: rgba(239,68,68,0.2); color: var(--danger); } +.badge-active { background: rgba(34,197,94,0.2); color: var(--success); } +.badge-inactive { background: rgba(136,153,187,0.2); color: var(--text-secondary); } +.badge-draft { background: rgba(136,153,187,0.2); color: var(--text-secondary); } +.badge-risk { background: rgba(245,158,11,0.2); color: var(--warning); } +.badge-crisis { background: rgba(239,68,68,0.2); color: var(--danger); } +.badge-stable { background: rgba(99,102,241,0.2); color: var(--info); } +.badge-growth { background: rgba(34,197,94,0.2); color: var(--success); } +.badge-default { background: rgba(136,153,187,0.2); color: var(--text-secondary); } + +/* ---------- MODAL ---------- */ +.modal-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.6); + display: none; + align-items: center; + justify-content: center; + z-index: 200; +} + +.modal-overlay.open { + display: flex; +} + +.modal { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px; + width: 100%; + max-width: 540px; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 8px 40px rgba(0,0,0,0.5); +} + +.modal h2 { + font-size: 18px; + font-weight: 700; + margin-bottom: 20px; +} + +.modal-close { + float: right; + background: none; + border: none; + color: var(--text-muted); + font-size: 20px; + cursor: pointer; +} + +/* ---------- TABS ---------- */ +.tabs { + display: flex; + border-bottom: 1px solid var(--border); + margin-bottom: 20px; + gap: 0; +} + +.tabs a { + padding: 10px 20px; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: all 0.2s; +} + +.tabs a:hover, +.tabs a.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* ---------- PAGINATION ---------- */ +.pagination { + display: flex; + gap: 4px; + justify-content: center; + margin-top: 20px; +} + +.pagination a, +.pagination span { + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-secondary); + font-size: 13px; +} + +.pagination a:hover { + border-color: var(--accent); + color: var(--accent); +} + +.pagination .current { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +/* ---------- SEARCH ---------- */ +.search-box { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.search-box input { + flex: 1; + padding: 8px 14px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-family: var(--font); + font-size: 13px; +} + +.search-box input:focus { + outline: none; + border-color: var(--border-focus); +} + +/* ---------- PROGRESS ---------- */ +.progress-bar { + height: 6px; + background: rgba(255,255,255,0.08); + border-radius: 3px; + overflow: hidden; + margin-top: 8px; +} + +.progress-fill { + height: 100%; + border-radius: 3px; + transition: width 0.4s; +} + +/* ---------- FORM INLINE ---------- */ +.form-row { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 14px; +} + +.form-check { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.form-check input[type="checkbox"], +.form-check input[type="radio"] { + width: auto; +} + +/* ---------- FILTER BAR ---------- */ +.filter-bar { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 16px; + align-items: center; +} + +.filter-bar select, +.filter-bar input { + padding: 6px 10px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-family: var(--font); + font-size: 12px; +} + +/* ---------- QUESTIONNAIRE ---------- */ +.questionnaire-step { + display: none; +} + +.questionnaire-step.active { + display: block; +} + +.step-indicators { + display: flex; + gap: 0; + margin-bottom: 28px; + overflow-x: auto; +} + +.step-dot { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + border-bottom: 2px solid var(--border); + white-space: nowrap; + cursor: default; +} + +.step-dot.completed { + color: var(--success); + border-bottom-color: var(--success); +} + +.step-dot.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* ---------- SHS ALERT ---------- */ +.shs-alert { + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + font-weight: 600; +} + +.shs-alert.warning { + background: rgba(245,158,11,0.15); + color: var(--warning); + border: 1px solid rgba(245,158,11,0.3); +} + +.shs-alert.critical { + background: rgba(239,68,68,0.15); + color: var(--danger); + border: 1px solid rgba(239,68,68,0.3); +} + +/* ---------- RESPONSIVE ---------- */ +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + } + .sidebar.open { + transform: translateX(0); + } + .sidebar-toggle { + display: block; + } + .main { + margin-left: 0; + padding: 16px; + padding-top: 56px; + } + .card-grid { + grid-template-columns: 1fr; + } + .form-row { + grid-template-columns: 1fr; + } + .login-box { + margin: 16px; + padding: 24px; + } + .tabs { + overflow-x: auto; + } + .step-indicators { + flex-wrap: nowrap; + } +} + +/* ---------- PRINT ---------- */ +@media print { + .sidebar, .sidebar-toggle, .main-header .actions { display: none; } + .main { margin-left: 0; } + .card { break-inside: avoid; } +} + +/* ---------- TOAST ---------- */ +.toast-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 999; +} + +.toast { + padding: 12px 20px; + border-radius: 6px; + margin-bottom: 8px; + font-size: 13px; + font-weight: 500; + box-shadow: 0 4px 20px rgba(0,0,0,0.4); + animation: slideIn 0.3s ease; +} + +.toast-success { background: var(--success); color: #fff; } +.toast-error { background: var(--danger); color: #fff; } +.toast-warning { background: var(--warning); color: #fff; } +.toast-info { background: var(--info); color: #fff; } + +@keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +/* ---------- TOOLTIPS ---------- */ +[data-tooltip] { + position: relative; +} +[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%) translateY(4px); + background: #1a1a2e; + color: #e8edf5; + padding: 6px 10px; + border-radius: 6px; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s, transform 0.2s, visibility 0.2s; + z-index: 1000; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); +} +[data-tooltip]::before { + content: ''; + position: absolute; + bottom: calc(100% + 2px); + left: 50%; + transform: translateX(-50%) translateY(4px); + border: 5px solid transparent; + border-top-color: #1a1a2e; + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s, transform 0.2s, visibility 0.2s; + z-index: 1000; +} +[data-tooltip]:hover::after, +[data-tooltip]:hover::before { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); +} + +/* ---------- EMPTY STATE ---------- */ +.empty-state { + text-align: center; + padding: 48px 20px; + color: var(--text-muted); +} + +.empty-state svg { + width: 60px; + height: 60px; + margin-bottom: 16px; + opacity: 0.4; +} + +.empty-state h3 { + font-size: 16px; + margin-bottom: 6px; +} + +.empty-state p { + font-size: 13px; +} + +/* ---------- LOADING ---------- */ +.loading { + display: flex; + align-items: center; + justify-content: center; + padding: 40px; +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ---------- NAVBAR ---------- */ +.navbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + height: 56px; + background: var(--bg-sidebar); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 50; +} + +.navbar__brand a { + font-family: 'Montserrat', sans-serif; + font-weight: 700; + font-size: 16px; + color: var(--text-primary); +} + +.navbar__menu { + display: flex; + align-items: center; + gap: 20px; + list-style: none; + margin: 0; + padding: 0; +} + +.navbar__menu a { + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + transition: color 0.2s; +} + +.navbar__menu a:hover { + color: var(--text-primary); +} + +.navbar__logout { + color: var(--text-muted) !important; +} + +/* ---------- DOCUMENTS ---------- */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 24px; +} + +.docs-list { + display: grid; + gap: 8px; + margin-bottom: 24px; +} + +.doc-item { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 20px; + transition: all 0.2s; +} + +.doc-item:hover { + border-color: var(--accent); + background: var(--bg-card-hover); +} + +.doc-item a { + font-size: 15px; + font-weight: 500; + color: var(--text-primary); +} + +.doc-item a:hover { + color: var(--accent); +} + +.docs-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.doc-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 16px; +} + +.doc-header h1 { + font-size: 24px; + font-weight: 700; +} + +.doc-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.doc-content { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px; + line-height: 1.7; +} + +.doc-content h1, .doc-content h2, .doc-content h3 { + margin-top: 24px; + margin-bottom: 12px; + color: var(--text-primary); +} + +.doc-content h1 { font-size: 22px; } +.doc-content h2 { font-size: 18px; } +.doc-content h3 { font-size: 16px; } + +.doc-content p { + margin-bottom: 12px; + color: var(--text-secondary); +} + +.doc-content code { + background: var(--bg-input); + padding: 2px 6px; + border-radius: 4px; + font-size: 13px; + color: var(--accent); +} + +.doc-content pre { + background: var(--bg-input); + padding: 16px; + border-radius: 6px; + overflow-x: auto; + margin-bottom: 16px; +} + +.doc-content pre code { + background: none; + padding: 0; +} + +.doc-content table { + margin-bottom: 16px; +} + +.doc-content ul, .doc-content ol { + margin-bottom: 12px; + padding-left: 24px; + color: var(--text-secondary); +} + +.doc-content li { + margin-bottom: 4px; +} + +.doc-content blockquote { + border-left: 3px solid var(--accent); + padding-left: 16px; + margin: 16px 0; + color: var(--text-muted); +} + +.doc-content a { + color: var(--accent); +} + +.doc-content a:hover { + color: var(--accent-hover); +} + +.form-actions { + display: flex; + gap: 10px; + margin-top: 16px; +} + +/* ---------- PERMISSIONS TABLE ---------- */ +.permissions-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; +} + +.permissions-table th { + text-align: left; + padding: 12px 16px; + font-size: 12px; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border); + background: var(--bg-sidebar); +} + +.permissions-table td { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +.permissions-table tr:hover td { + background: rgba(0, 173, 239, 0.05); +} + +.permissions-table input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent); + cursor: pointer; +} + +/* ---------- ACCORDION ---------- */ +.accordion { + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 8px; + overflow: hidden; +} +.accordion-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-sidebar); + cursor: pointer; + user-select: none; + transition: background 0.2s; +} +.accordion-header:hover { + background: var(--bg-card-hover); +} +.accordion-header-left { + display: flex; + align-items: center; + gap: 10px; +} +.accordion-header-right { + display: flex; + align-items: center; + gap: 8px; +} +.accordion-arrow { + font-size: 10px; + color: var(--text-muted); + transition: transform 0.2s; + width: 12px; +} +.accordion-title { + font-weight: 600; + font-size: 14px; +} +.accordion-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 6px; + border-radius: 11px; + background: var(--accent-dim); + color: var(--accent); + font-size: 11px; + font-weight: 700; +} +.accordion-formula, .accordion-action { + cursor: pointer; + font-size: 16px; + opacity: 0.6; + transition: opacity 0.2s; + line-height: 1; +} +.accordion-formula:hover, .accordion-action:hover { + opacity: 1; +} +.accordion-body { + display: none; + border-top: 1px solid var(--border); +} +.accordion-body .table-wrap { + margin: 0; +} + +@media (max-width: 768px) { + .navbar { + flex-direction: column; + height: auto; + padding: 12px 16px; + gap: 12px; + } + .navbar__menu { + flex-wrap: wrap; + justify-content: center; + } + .doc-header { + flex-direction: column; + } + .container { + padding: 16px; + } +} + diff --git a/py_service/app/static/img/aegisone_logo.svg b/py_service/app/static/img/aegisone_logo.svg new file mode 100644 index 0000000..596c7dc --- /dev/null +++ b/py_service/app/static/img/aegisone_logo.svg @@ -0,0 +1,3 @@ + + AegisOne + diff --git a/py_service/app/static/img/favicon.svg b/py_service/app/static/img/favicon.svg new file mode 100644 index 0000000..74e806c --- /dev/null +++ b/py_service/app/static/img/favicon.svg @@ -0,0 +1 @@ +AO diff --git a/py_service/app/static/img/favicons/android-chrome-192x192.png b/py_service/app/static/img/favicons/android-chrome-192x192.png new file mode 100644 index 0000000..46d5e3e Binary files /dev/null and b/py_service/app/static/img/favicons/android-chrome-192x192.png differ diff --git a/py_service/app/static/img/favicons/apple-touch-icon.png b/py_service/app/static/img/favicons/apple-touch-icon.png new file mode 100644 index 0000000..6c9b56e Binary files /dev/null and b/py_service/app/static/img/favicons/apple-touch-icon.png differ diff --git a/py_service/app/static/img/favicons/favicon-16x16.png b/py_service/app/static/img/favicons/favicon-16x16.png new file mode 100644 index 0000000..7d47fb9 Binary files /dev/null and b/py_service/app/static/img/favicons/favicon-16x16.png differ diff --git a/py_service/app/static/img/favicons/favicon-32x32.png b/py_service/app/static/img/favicons/favicon-32x32.png new file mode 100644 index 0000000..be0b500 Binary files /dev/null and b/py_service/app/static/img/favicons/favicon-32x32.png differ diff --git a/py_service/app/static/img/favicons/favicon.ico b/py_service/app/static/img/favicons/favicon.ico new file mode 100644 index 0000000..7d97fd7 Binary files /dev/null and b/py_service/app/static/img/favicons/favicon.ico differ diff --git a/py_service/app/static/img/favicons/logo_service.png b/py_service/app/static/img/favicons/logo_service.png new file mode 100644 index 0000000..02fb822 Binary files /dev/null and b/py_service/app/static/img/favicons/logo_service.png differ diff --git a/py_service/app/static/img/favicons/site.webmanifest b/py_service/app/static/img/favicons/site.webmanifest new file mode 100644 index 0000000..7bafe96 --- /dev/null +++ b/py_service/app/static/img/favicons/site.webmanifest @@ -0,0 +1 @@ +{"name":"AegisOne Service Portal","short_name":"AegisOne","icons":[{"src":"/service/static/img/favicons/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/service/static/img/favicons/apple-touch-icon.png","sizes":"180x180","type":"image/png"},{"src":"/service/static/img/favicons/favicon-16x16.png","sizes":"16x16","type":"image/png"},{"src":"/service/static/img/favicons/favicon-32x32.png","sizes":"32x32","type":"image/png"},{"src":"/service/static/img/favicons/favicon.ico","sizes":"48x48","type":"image/x-icon"}]} diff --git a/py_service/app/static/js/theme.js b/py_service/app/static/js/theme.js new file mode 100644 index 0000000..23c3408 --- /dev/null +++ b/py_service/app/static/js/theme.js @@ -0,0 +1,47 @@ +(function() { + var STORAGE_KEY = 'aegisone_service_theme'; + var themes = ['system', 'dark', 'light']; + + function getSystemTheme() { + return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; + } + + function applyTheme(theme) { + var resolved = theme === 'system' ? getSystemTheme() : theme; + document.documentElement.setAttribute('data-theme', resolved); + var label = document.getElementById('theme-label'); + if (label) { + var names = { system: 'Авто', dark: 'Тёмная', light: 'Светлая' }; + label.textContent = names[theme] || 'Авто'; + } + var btn = document.getElementById('theme-btn'); + if (btn) { + var icons = { system: '🖥', dark: '🌙', light: '☀️' }; + btn.textContent = icons[theme] || '🖥'; + } + } + + function getNextTheme(current) { + var idx = themes.indexOf(current); + return themes[(idx + 1) % themes.length]; + } + + function toggleTheme() { + var current = localStorage.getItem(STORAGE_KEY) || 'system'; + var next = getNextTheme(current); + localStorage.setItem(STORAGE_KEY, next); + applyTheme(next); + } + + var saved = localStorage.getItem(STORAGE_KEY) || 'system'; + applyTheme(saved); + + window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function() { + var current = localStorage.getItem(STORAGE_KEY) || 'system'; + if (current === 'system') { + applyTheme('system'); + } + }); + + window.toggleTheme = toggleTheme; +})(); diff --git a/py_service/app/templates/base.html b/py_service/app/templates/base.html index 977dd14..2898691 100644 --- a/py_service/app/templates/base.html +++ b/py_service/app/templates/base.html @@ -5,12 +5,32 @@ {% block title %}Service Portal — AegisOne Engineering{% endblock %} - - + + + + + + + + {% block extra_head %}{% endblock %} +{% if user %} + +{% endif %} {% block content %}{% endblock %} + {% block extra_scripts %}{% endblock %} diff --git a/py_service/app/templates/dashboard.html b/py_service/app/templates/dashboard.html index 10314e5..3e3881e 100644 --- a/py_service/app/templates/dashboard.html +++ b/py_service/app/templates/dashboard.html @@ -5,8 +5,8 @@

Дашборд

- +
{{ now }} @@ -117,10 +125,132 @@ {% endif %}
+
+ +
+ + + + + + {% endblock %} diff --git a/py_service/app/templates/documents/edit.html b/py_service/app/templates/documents/edit.html new file mode 100644 index 0000000..161ff4c --- /dev/null +++ b/py_service/app/templates/documents/edit.html @@ -0,0 +1,29 @@ +{% extends "page.html" %} +{% block breadcrumb %}Документация / Редактирование{% endblock %} +{% block page_actions %}← Назад{% endblock %} +{% block page_content %} +
+
+
+ +
+
+ + Отмена +
+
+
+ + + +{% endblock %} diff --git a/py_service/app/templates/documents/list.html b/py_service/app/templates/documents/list.html new file mode 100644 index 0000000..8ff60c2 --- /dev/null +++ b/py_service/app/templates/documents/list.html @@ -0,0 +1,30 @@ +{% extends "page.html" %} +{% block breadcrumb %}Документация{% endblock %} +{% block page_content %} +
+

Документация

+
+ {% for doc in docs %} + + {% else %} +

Нет доступных документов

+ {% endfor %} +
+ {% if user.role == "owner" %} +
+ Управление доступами + +
+ + {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/py_service/app/templates/documents/permissions.html b/py_service/app/templates/documents/permissions.html new file mode 100644 index 0000000..823032f --- /dev/null +++ b/py_service/app/templates/documents/permissions.html @@ -0,0 +1,39 @@ +{% extends "page.html" %} +{% block breadcrumb %}Документация / Управление доступами{% endblock %} +{% block page_actions %}← Назад{% endblock %} +{% block page_content %} +
+
+ + + + + + + + + + + {% for slug, doc in perms.items()|sort(attribute='value.sort_order') %} + + + + + + + {% endfor %} + +
ДокументEngineer: ПросмотрEngineer: РедактированиеTechnician: Просмотр
{{ doc.title }} + + + + + +
+
+ + Отмена +
+
+
+{% endblock %} diff --git a/py_service/app/templates/documents/tech_access.html b/py_service/app/templates/documents/tech_access.html new file mode 100644 index 0000000..85857b7 --- /dev/null +++ b/py_service/app/templates/documents/tech_access.html @@ -0,0 +1,36 @@ +{% extends "page.html" %} +{% block breadcrumb %}Документация / Доступ техников{% endblock %} +{% block page_actions %}← Назад{% endblock %} +{% block page_content %} +
+

Вы можете открыть техникам просмотр документов, к которым у вас есть доступ на редактирование.

+
+ + + + + + + + + {% for slug, doc in perms.items() %} + {% if doc.permissions.engineer.edit %} + + + + + {% endif %} + {% else %} + + {% endfor %} + +
ДокументTechnician: Просмотр
{{ doc.title }} + +
Нет документов с правом редактирования
+
+ + Отмена +
+
+
+{% endblock %} diff --git a/py_service/app/templates/documents/view.html b/py_service/app/templates/documents/view.html new file mode 100644 index 0000000..f8876b9 --- /dev/null +++ b/py_service/app/templates/documents/view.html @@ -0,0 +1,16 @@ +{% extends "page.html" %} +{% block breadcrumb %}Документация{% endblock %} +{% block page_actions %} + {% if can_edit %} + Редактировать + {% endif %} + {% if can_download %} + Скачать + {% endif %} + ← Назад +{% endblock %} +{% block page_content %} +
+
{{ content | safe }}
+
+{% endblock %} \ No newline at end of file diff --git a/py_service/app/templates/login.html b/py_service/app/templates/login.html index 57e0b8c..c6a6a37 100644 --- a/py_service/app/templates/login.html +++ b/py_service/app/templates/login.html @@ -3,8 +3,8 @@ {% block content %}
- - + +
diff --git a/py_service/app/templates/page.html b/py_service/app/templates/page.html new file mode 100644 index 0000000..fdd656a --- /dev/null +++ b/py_service/app/templates/page.html @@ -0,0 +1,165 @@ +{% extends "base.html" %} +{% block body_class %}{{ page_name|default('dashboard-page') }}{% endblock %} +{% block content %} + +
+ +
+
+
+

{% block page_title %}{{ title|default(page_name|capitalize) }}{% endblock %}

+ +
+
+ {% block page_actions %}{% endblock %} + {{ now }} +
+
+ {% block page_content %}{% endblock %} +
+
+ + + +{% endblock %} +{% block extra_scripts %}{% endblock %} diff --git a/py_service/app/templates/pages/assignments.html b/py_service/app/templates/pages/assignments.html new file mode 100644 index 0000000..a646f42 --- /dev/null +++ b/py_service/app/templates/pages/assignments.html @@ -0,0 +1,31 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Назначение сотрудников

+ +
+
+ {% for a in assignments %} + + + + + {% endfor %}
ОбъектСотрудникРольНазначен
{{ a.object.name if a.object else '' }}{{ a.user.full_name if a.user else '' }}{{ {'engineer':'Инженер','technician':'Техник'}[a.role] }}{{ a.assigned_at.strftime('%d.%m.%Y %H:%M') if a.assigned_at else '' }} +
+ + +
+
+
+
+ +{% endblock %} diff --git a/py_service/app/templates/pages/blog.html b/py_service/app/templates/pages/blog.html new file mode 100644 index 0000000..608a9a5 --- /dev/null +++ b/py_service/app/templates/pages/blog.html @@ -0,0 +1,50 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Управление блогом

+ +
+
+ {% for p, author in posts %} + + + + + {% endfor %}
НазваниеКатегорияАвторСтатусДата
{{ p.title }}{{ p.category }}{{ author }}{{ {'draft':'Черновик','published':'Опубликован','archived':'Архив'}[p.status] }}{{ p.created_at.strftime('%d.%m.%Y') if p.created_at else '' }} + +
+
+
+
+ + +{% endblock %} diff --git a/py_service/app/templates/pages/cases.html b/py_service/app/templates/pages/cases.html new file mode 100644 index 0000000..1949c2e --- /dev/null +++ b/py_service/app/templates/pages/cases.html @@ -0,0 +1,47 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Примеры из практики

+ +
+
+ {% for c in cases %} + + + + {% endfor %}
IDНазваниеПорядокАктивен
{{ c.id }}{{ c.title }}{{ c.sort_order }}{{ 'Да' if c.is_active else 'Нет' }} + +
+
+
+
+ + +{% endblock %} diff --git a/py_service/app/templates/pages/ceo.html b/py_service/app/templates/pages/ceo.html new file mode 100644 index 0000000..ecb8869 --- /dev/null +++ b/py_service/app/templates/pages/ceo.html @@ -0,0 +1,45 @@ +{% extends "page.html" %} +{% block page_content %} +{% if latest_shs %} +
+
SHS Score
{{ "%.2f"|format(latest_shs.shs_score) }}
{{ latest_shs.shs_status }}
+
MRR
{{ "%.2f"|format(mrr) }} ₽
+
SLA Compliance
{{ sla_compliance }}%
+
Активные объекты
{{ active_objects }}
+
Сотрудники
{{ active_engineers }}
+
Активные SLA
{{ active_contracts }}
+
Инциденты (30д)
{{ incidents_30 }}
+
Avg Engineer Score
{{ avg_es }}
+
+{% endif %} + +
+

История SHS

+
+
+
+
+ {% for s in shs_history %} + + + + + + + {% endfor %}
ПериодSHSСтатусMRRRetentionСотрудники
{{ s.period_start.strftime('%d.%m.%Y') if s.period_start else '' }} — {{ s.period_end.strftime('%d.%m.%Y') if s.period_end else '' }}{{ "%.2f"|format(s.shs_score) }}{{ s.shs_status }}{{ "%.2f"|format(s.mrr or 0) }} ₽{{ "%.1f"|format(s.retention_rate or 0) }}%{{ s.employee_count or 0 }}
+
+
+ +
+

Последние KPI инженеров

+
+ {% for kpi, name in kpi_data %} + + + + + + {% endfor %}
ИнженерПериодSLA ComplianceEngineer ScoreGrade
{{ name }}{{ kpi.period_start.strftime('%d.%m.%Y') if kpi.period_start else '' }} — {{ kpi.period_end.strftime('%d.%m.%Y') if kpi.period_end else '' }}{{ "%.1f"|format(kpi.sla_compliance or 0) }}%{{ "%.2f"|format(kpi.engineer_score or 0) }}{{ kpi.grade or '—' }}
+
+
+{% endblock %} diff --git a/py_service/app/templates/pages/charts.html b/py_service/app/templates/pages/charts.html new file mode 100644 index 0000000..14715c8 --- /dev/null +++ b/py_service/app/templates/pages/charts.html @@ -0,0 +1,158 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Графики и диаграммы

+
+ + + + + + +
+
+ Пользовательские графики: + + + +
+
+ +
+
+ + + +{% endblock %} diff --git a/py_service/app/templates/pages/checklist.html b/py_service/app/templates/pages/checklist.html new file mode 100644 index 0000000..9e13016 --- /dev/null +++ b/py_service/app/templates/pages/checklist.html @@ -0,0 +1,32 @@ +{% extends "page.html" %} +{% block page_content %} +{% for t in tasks %} +
+

{{ t.title }}

+
+ {{ t.object.name if t.object else '' }} — {{ t.object.address if t.object else '' }} + {{ t.priority }} +
+ {% if t.description %}

{{ t.description }}

{% endif %} +
+ +
+ + + + + + + + + + +
+
+ +
+
+{% else %} +

Нет активных задач

У вас нет открытых задач для выполнения.

+{% endfor %} +{% endblock %} diff --git a/py_service/app/templates/pages/coefficients.html b/py_service/app/templates/pages/coefficients.html new file mode 100644 index 0000000..d00c5d8 --- /dev/null +++ b/py_service/app/templates/pages/coefficients.html @@ -0,0 +1,107 @@ +{% extends "page.html" %} +{% block page_content %} +
+
+

Коэффициенты формул

+
+ 🧪 Тест формул + +
+
+ {% if groups|length == 0 %} +

Нет коэффициентов

+ {% else %} + {% for gkey, gdata in groups.items() %} +
+
+
+ + {{ gdata.info.name }} + {{ gdata.coeffs|length }} +
+
+ 📐 + 🧪 +
+
+
+
+ {% for c in gdata.coeffs %} + + + + + + + {% endfor %}
КлючЗначениеОписаниеФормулаАктивен
{{ c.key }}{{ c.value }}{{ c.description }}{{ c.formula_ref }}
+
+
+
+
+
+ {% endfor %} + {% endif %} +
+ + + + + + +{% endblock %} diff --git a/py_service/app/templates/pages/customers.html b/py_service/app/templates/pages/customers.html new file mode 100644 index 0000000..ff57a3b --- /dev/null +++ b/py_service/app/templates/pages/customers.html @@ -0,0 +1,90 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Клиенты

+ {% if error %}
{{ error }}
{% endif %} +
+ +
+ +
+ + {% for d in customers %} + + + + + + + {% endfor %}
НазваниеИННКонтактыСтатусОбъектыАктивные SLA
{{ d.c.name }}{{ d.c.inn }}{{ d.c.contact_person }}
{{ d.c.contact_phone }}
{{ {'active':'Активен','inactive':'Неактивен','prospect':'Перспективный'}[d.c.status] }}{{ d.objects_count }}{{ d.active_contracts }} + + {% if user.role == 'owner' %} +
+ +
{% endif %} +
+
+
+ + +{% endblock %} diff --git a/py_service/app/templates/pages/docs.html b/py_service/app/templates/pages/docs.html new file mode 100644 index 0000000..bc411e6 --- /dev/null +++ b/py_service/app/templates/pages/docs.html @@ -0,0 +1,14 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Документация

+ {% if error %}
{{ error }}
{% endif %} + {% if docs %} +
{% for d in docs %} + + {% endfor %}
+ {% else %} +

Нет доступных документов

+ {% endif %} +
+{% endblock %} diff --git a/py_service/app/templates/pages/docs_manage.html b/py_service/app/templates/pages/docs_manage.html new file mode 100644 index 0000000..e795fec --- /dev/null +++ b/py_service/app/templates/pages/docs_manage.html @@ -0,0 +1,17 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Управление документами

+

Управление доступом к документам для инженеров. Отметьте документы, которые будут видны.

+
+
+ {% for d in docs %} + + + + {% endfor %}
ДокументДоступен инженерам
{{ d.title }}
+
+
+
+
+{% endblock %} diff --git a/py_service/app/templates/pages/docs_view.html b/py_service/app/templates/pages/docs_view.html new file mode 100644 index 0000000..68cfe41 --- /dev/null +++ b/py_service/app/templates/pages/docs_view.html @@ -0,0 +1,8 @@ +{% extends "page.html" %} +{% block breadcrumb %}Документация{% endblock %} +{% block page_actions %}← Назад{% endblock %} +{% block page_content %} +
+
{{ content|safe }}
+
+{% endblock %} diff --git a/py_service/app/templates/pages/formula_test.html b/py_service/app/templates/pages/formula_test.html new file mode 100644 index 0000000..0a9b8b0 --- /dev/null +++ b/py_service/app/templates/pages/formula_test.html @@ -0,0 +1,145 @@ +{% extends "page.html" %} +{% block page_content %} +
+
+

🧪 Тест формул

+ ← Коэффициенты +
+
+
+ + +
+ + {% for f in formulas %} +
+
+
Формула:
+
{{ f.formula }}
+
Результат: {{ f.result }}
+
+ + {% if f.sandbox %} +
+

Параметры расчёта

+
+ {% for inp in f.sandbox.inputs %} +
+ + {% if inp.type == "checkbox" %} + + {% elif inp.type == "select" %} + + {% else %} + + {% endif %} +
+ {% endfor %} +
+ + +
+ {% else %} +
+

Для этой формулы пока нет тестовой песочницы. Используйте коэффициенты на странице управления коэффициентами.

+
+ {% endif %} + +

Связанные коэффициенты

+
+ {% for c in f.coeffs %} + + + + {% endfor %}
КлючЗначениеОписание
{{ c.key }}{{ c.value }}{{ c.description }}
+
+
+ {% endfor %} +
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/py_service/app/templates/pages/ideas.html b/py_service/app/templates/pages/ideas.html new file mode 100644 index 0000000..ed1c1ef --- /dev/null +++ b/py_service/app/templates/pages/ideas.html @@ -0,0 +1,55 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Система идей

+ +
+
+ {% for idea in ideas %} + + + + + + + {% endfor %}
IDНазваниеОписаниеСтатусДата
{{ idea.id }}{{ idea.title }}{{ (idea.description or '')[:100] }}{% if (idea.description or '')|length > 100 %}…{% endif %}{{ {'normal':'Обычная','in_progress':'В работе','completed':'Выполнено'}.get(idea.status, idea.status) }}{{ idea.updated_at.strftime('%d.%m.%Y') if idea.updated_at else '' }} +
+ + +
+ +
+ + +
+
+
+
+ + + + +{% endblock %} diff --git a/py_service/app/templates/pages/incidents.html b/py_service/app/templates/pages/incidents.html new file mode 100644 index 0000000..f4c9c81 --- /dev/null +++ b/py_service/app/templates/pages/incidents.html @@ -0,0 +1,55 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Инциденты

+ {% if user.role in ['owner','engineer'] %} + + {% endif %} +
+
+ + +
+
+ {% for i in incidents %} + + + + + + + {% endfor %}
ВажностьНазваниеОбъектИсполнительСтатусСоздан
{{ i.severity }}{{ i.title }}{{ i.object.name if i.object else '' }}{{ i.assignee.full_name if i.assignee else '' }}{{ {'open':'Открыт','in_progress':'В работе','resolved':'Решён','closed':'Закрыт'}[i.status] }}{{ i.created_at.strftime('%d.%m.%Y') if i.created_at else '' }}{% if i.status in ['open','in_progress'] %} + + {% endif %} +
+
+
+ + + +{% endblock %} diff --git a/py_service/app/templates/pages/objects.html b/py_service/app/templates/pages/objects.html new file mode 100644 index 0000000..e2a67be --- /dev/null +++ b/py_service/app/templates/pages/objects.html @@ -0,0 +1,67 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Объекты

+ +
+
+ + + +
+
+ {% for o in objects %} + + + + + {% endfor %}
НазваниеКлиентТипАдресСтатус
{{ o.name }}{{ o.customer.name if o.customer else '' }}{{ o.object_type }}{{ o.address }}{{ {'active':'Активен','inactive':'Неактивен','audit':'Аудит','prospective':'Перспективный'}[o.status] }} + + {% if user.role == 'owner' %} +
+ {% endif %} +
+
+
+ + +{% endblock %} diff --git a/py_service/app/templates/pages/passports.html b/py_service/app/templates/pages/passports.html new file mode 100644 index 0000000..faaaaaf --- /dev/null +++ b/py_service/app/templates/pages/passports.html @@ -0,0 +1,16 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Паспорта объектов

+
+ {% for p in passports %} + + + + + + + {% endfor %}
ОбъектАвторObject IndexRisk ScoreSLA PriceДата
{{ p.object.name if p.object else '' }}{{ p.creator.full_name if p.creator else '' }}{{ "%.2f"|format(p.object_index) if p.object_index else '—' }}{{ "%.2f"|format(p.risk_score) if p.risk_score else '—' }}{{ "%.2f"|format(p.sla_price_monthly) if p.sla_price_monthly else '—' }} ₽{{ p.created_at.strftime('%d.%m.%Y') if p.created_at else '' }}
+
+
+{% endblock %} diff --git a/py_service/app/templates/pages/questionnaire.html b/py_service/app/templates/pages/questionnaire.html new file mode 100644 index 0000000..b486704 --- /dev/null +++ b/py_service/app/templates/pages/questionnaire.html @@ -0,0 +1,16 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Опросники

+ + Новый опросник +
+
+ {% for s, author in sessions %} + + + + + {% endfor %}
IDКлиентАвторСтатусШагДата
#{{ s.id }}{{ s.client_name }}{{ author }}{{ {'draft':'Черновик','completed':'Завершён','cancelled':'Отменён'}[s.status] }}{{ s.current_step }}/5{{ s.created_at.strftime('%d.%m.%Y') if s.created_at else '' }}
+
+
+{% endblock %} diff --git a/py_service/app/templates/pages/questionnaire_config.html b/py_service/app/templates/pages/questionnaire_config.html new file mode 100644 index 0000000..3ce1e2d --- /dev/null +++ b/py_service/app/templates/pages/questionnaire_config.html @@ -0,0 +1,59 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Настройка опросника

+ +
+
Все{% for s in range(1,6) %}Шаг {{ s }}{% endfor %}
+
+ {% for qi in items %} + + + + + {% endfor %}
ШагРазделКлючВопросТипОбязат.ПорядокАктивен
{{ qi.step }}{{ qi.section }}{{ qi.question_key }}{{ qi.label }}{{ qi.type }}{{ '✓' if qi.required else '—' }}{{ qi.sort_order }}
+ +
+
+
+
+ + +{% endblock %} diff --git a/py_service/app/templates/pages/questionnaire_step.html b/py_service/app/templates/pages/questionnaire_step.html new file mode 100644 index 0000000..0aa819e --- /dev/null +++ b/py_service/app/templates/pages/questionnaire_step.html @@ -0,0 +1,53 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Шаг {{ step }} из 5

+
{% for s in range(1,6) %}Шаг {{ s }}{% endfor %}
+
+
+ {% if session_id %}{% endif %} + {% for item in items %} +
+ + {% if item.type == 'text' %} + {% elif item.type == 'number' %} + {% elif item.type == 'textarea' %} + {% elif item.type in ('select','radio') %} + + {% elif item.type == 'checkbox' %}
{% for opt in (item.options or {}).get('options', []) %} + {% endfor %}
+ {% endif %} + {% if item.help_text %}{{ item.help_text }}{% endif %} +
+ {% endfor %} +
+ {% if step > 1 %}← Назад{% endif %} + {% if step < 5 %}{% else %}{% endif %} + +
+
+
+ + +{% endblock %} diff --git a/py_service/app/templates/pages/quickmenu.html b/py_service/app/templates/pages/quickmenu.html new file mode 100644 index 0000000..0cd65d7 --- /dev/null +++ b/py_service/app/templates/pages/quickmenu.html @@ -0,0 +1,38 @@ +{% extends "page.html" %} +{% block page_content %} +
+

{% if editing_tech %}Меню для техников{% else %}Быстрое меню{% endif %}

+ Настройте ссылки, отображаемые в верхней панели навигации +
+
+
+ + {% for item in items %} + + + + + + + {% endfor %} + +
#НазваниеСсылка
{{ loop.index }}
+ {% if items|length == 0 %} +

Нет элементов

Добавьте ссылки для быстрого доступа

+ {% endif %} +
+ Отмена
+
+
+ +{% endblock %} diff --git a/py_service/app/templates/pages/reports.html b/py_service/app/templates/pages/reports.html new file mode 100644 index 0000000..a2711cd --- /dev/null +++ b/py_service/app/templates/pages/reports.html @@ -0,0 +1,37 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Отчёты

+ +
+
+ +
+
+ {% for r in reports %} + + + + + + {% endfor %}
ТипНазваниеОбъектСтатусРезультатДата
{{ {'inspection':'Осмотр','service':'Обслуживание','emergency':'Авария','audit':'Аудит','monthly':'Ежемесячный'}[r.report_type] }}{{ r.title }}{{ r.object.name if r.object else '' }}{{ {'draft':'Черновик','final':'Готов','cancelled':'Отменён'}[r.status] }}{{ {'ok':'OK','fixed':'Исправлено','partial':'Частично','revisit':'Повторный'}[r.result_status] }}{{ r.created_at.strftime('%d.%m.%Y') if r.created_at else '' }}
+
+
+ +{% endblock %} diff --git a/py_service/app/templates/pages/sla.html b/py_service/app/templates/pages/sla.html new file mode 100644 index 0000000..a10c82d --- /dev/null +++ b/py_service/app/templates/pages/sla.html @@ -0,0 +1,54 @@ +{% extends "page.html" %} +{% block page_content %} +
+

SLA контракты

+ +
+
+ +
+
+ {% for sc in contracts %} + + + + + + + {% endfor %}
ОбъектУровеньЦена/месПериодСтатус
{{ sc.object.name if sc.object else '' }}{{ {'start':'Базовый','business':'Оптимальный','enterprise':'Максимальный'}.get(sc.service_level, sc.service_level) }}{{ "%.2f"|format(sc.sla_price_monthly) }} ₽{{ sc.start_date.strftime('%d.%m.%Y') if sc.start_date else '' }} — {{ sc.end_date.strftime('%d.%m.%Y') if sc.end_date else '∞' }}{{ {'active':'Активен','expired':'Истёк','cancelled':'Отменён','negotiation':'Переговоры'}.get(sc.status, sc.status) }}
+
+
+ + +{% endblock %} diff --git a/py_service/app/templates/pages/tasks.html b/py_service/app/templates/pages/tasks.html new file mode 100644 index 0000000..1976906 --- /dev/null +++ b/py_service/app/templates/pages/tasks.html @@ -0,0 +1,46 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Задачи

+ {% if user.role in ['owner','engineer'] %} + + {% endif %} +
+
+ + + +
+
+ {% for t in tasks %} + + + + + + + {% endfor %}
ПриоритетНазваниеОбъектИсполнительСтатусСрок
{{ t.priority }}{{ t.title }}{{ t.object.name if t.object else '' }}{{ t.assignee.full_name if t.assignee else '' }}{{ {'open':'Открыта','in_progress':'В работе','completed':'Завершена','cancelled':'Отменена'}[t.status] }}{{ t.deadline.strftime('%d.%m.%Y') if t.deadline else '' }}{% if t.status in ['open','in_progress'] and (user.role in ['owner','engineer'] or t.assigned_to == user.id) %} +
+ {% endif %} +
+
+
+ +{% endblock %} diff --git a/py_service/app/templates/pages/users.html b/py_service/app/templates/pages/users.html new file mode 100644 index 0000000..bcdfebf --- /dev/null +++ b/py_service/app/templates/pages/users.html @@ -0,0 +1,67 @@ +{% extends "page.html" %} +{% block page_content %} +
+

Сотрудники

+ +
+
+ + {% for u in users %} + + + + + + + {% endfor %}
ЛогинФИОРольТелефонEmailСтатусСоздан
{{ u.login }}{{ u.full_name }}{{ {'owner':'Владелец','engineer':'Инженер','technician':'Техник'}[u.role] }}{{ u.phone }}{{ u.email }}{{ 'Активен' if u.is_active else 'Неактивен' }}{{ u.created_at.strftime('%d.%m.%Y') if u.created_at else '' }} + + {% if u.role != 'owner' %} +
+ + +
{% endif %} +
+
+
+ + +{% endblock %} diff --git a/py_service/app/templating.py b/py_service/app/templating.py new file mode 100644 index 0000000..99c752e --- /dev/null +++ b/py_service/app/templating.py @@ -0,0 +1,10 @@ +from fastapi.templating import Jinja2Templates +from app.config import get_settings +from app.version import get_app_version + +settings = get_settings() + +templates = Jinja2Templates(directory="app/templates") +templates.env.globals["SITE_NAME"] = "AegisOne Engineering" +templates.env.globals["app_version"] = get_app_version() +templates.env.globals["PORTAINER_URL"] = settings.PORTAINER_URL diff --git a/py_service/app/version.py b/py_service/app/version.py new file mode 100644 index 0000000..8078fae --- /dev/null +++ b/py_service/app/version.py @@ -0,0 +1,15 @@ +import os +from pathlib import Path + +VERSION_FILE = Path(__file__).parent.parent / "version.txt" + + +def read_version() -> str: + try: + return VERSION_FILE.read_text().strip() + except (FileNotFoundError, OSError): + return "0.0.1" + + +def get_app_version() -> str: + return read_version() diff --git a/py_service/scripts/bump_version.py b/py_service/scripts/bump_version.py new file mode 100644 index 0000000..8abe1de --- /dev/null +++ b/py_service/scripts/bump_version.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Auto-increment version and update changelog on deploy.""" + +import os +import sys +import json +from datetime import datetime +from pathlib import Path + +BASE = Path(__file__).resolve().parent.parent +VERSION_FILE = BASE / "version.txt" +CHANGELOG_FILE = BASE / "CHANGELOG.md" + + +def read_version() -> tuple: + try: + parts = VERSION_FILE.read_text().strip().split(".") + return tuple(int(p) for p in parts[:3]) + except (FileNotFoundError, ValueError): + return (0, 0, 1) + + +def write_version(major: int, minor: int, patch: int): + VERSION_FILE.write_text(f"{major}.{minor}.{patch}\n") + + +def bump_version(bump_type: str = "patch") -> str: + major, minor, patch = read_version() + if bump_type == "major": + major += 1; minor = 0; patch = 0 + elif bump_type == "minor": + minor += 1; patch = 0 + else: + patch += 1 + write_version(major, minor, patch) + return f"{major}.{minor}.{patch}" + + +def add_changelog_entry(version: str, features: list = None, fixes: list = None): + date = datetime.now().strftime("%d.%m.%Y") + lines = [f"\n## {version} ({date})"] + if features: + lines.append("### Новые функции\n" + "\n".join(f"- {f}" for f in features)) + if fixes: + lines.append("### Исправления и улучшения\n" + "\n".join(f"- {f}" for f in fixes)) + entry = "\n".join(lines) + "\n" + changelog = CHANGELOG_FILE.read_text(encoding="utf-8") if CHANGELOG_FILE.exists() else "# Changelog — AegisOne Service Portal\n" + # Insert after the title line + if changelog.startswith("# Changelog"): + header_end = changelog.find("\n", changelog.find("\n", 2) + 1) + if header_end == -1: + changelog += entry + else: + changelog = changelog[:header_end] + entry + changelog[header_end:] + else: + changelog = f"# Changelog — AegisOne Service Portal\n{entry}\n" + changelog + CHANGELOG_FILE.write_text(changelog, encoding="utf-8") + + +if __name__ == "__main__": + bump = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] in ("major", "minor", "patch") else "patch" + features = [] + fixes = [] + if len(sys.argv) > 2: + try: + parsed = json.loads(sys.argv[2]) + features = parsed.get("features", []) + fixes = parsed.get("fixes", []) + except (json.JSONDecodeError, TypeError): + pass + version = bump_version(bump) + add_changelog_entry(version, features, fixes) + print(f"Version bumped to {version}") diff --git a/py_service/version.txt b/py_service/version.txt new file mode 100644 index 0000000..88c5fb8 --- /dev/null +++ b/py_service/version.txt @@ -0,0 +1 @@ +1.4.0