Files
tech-service-portal/app/main.py
T
darkorion e67c175f80 feat: документация из Nextcloud, график у сотрудника, автоочистка фото, мониторинг, итог мастера, фильтр по местам; доп. сервисы в docker-compose
- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели)
- автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов
  во внутренней сети Docker; значения из интерфейса важнее
- локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API
- .env.example, docs/DEPLOY.md (раздел 7а), README
2026-10-13 13:27:20 +03:00

240 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import re
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import APIRouter, FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select
from . import backup, config, demo, docs_index, logic, mode, photo_cleanup, push
from .db import SessionLocal, real_engine
from .migrate import ensure_schema
from .models import User
from .routers import catalog, orders, extras, reports, service as service_router, demo as demo_router
from .routers import inventory, incidents, tasks, portal, external, history, backups, assistant as assistant_router
from .routers import reliability as reliability_router, feedback as feedback_router, docs as docs_router
from .security import hash_password
log = logging.getLogger("scheduler")
STATIC = Path(__file__).parent / "static"
def check_secrets(users_exist: bool = True):
"""В боевом режиме (APP_ENV=production) слабый ключ подписи — отказ запуска: иначе токены можно подделать."""
weak = config.SECRET_KEY in ("", "dev-secret-change-me") or len(config.SECRET_KEY) < 32
if weak:
msg = "SECRET_KEY не задан или слишком короткий (нужно не менее 32 случайных символов)"
if config.PRODUCTION:
raise RuntimeError(msg)
log.warning("%s — допустимо только для разработки", msg)
if not users_exist and (len(config.ADMIN_PASSWORD) < 10 or config.ADMIN_PASSWORD == "admin"):
msg = "ADMIN_PASSWORD слабый (нужно не менее 10 символов, не «admin»)"
if config.PRODUCTION:
raise RuntimeError(msg)
log.warning("%s", msg)
def bootstrap():
check_secrets()
ensure_schema(real_engine)
demo.activate_on_startup() # демо-БД (если была) подключается снова — для тех, кто в демо
with SessionLocal() as db:
if not db.scalar(select(User).limit(1)):
check_secrets(users_exist=False)
db.add(User(username=config.ADMIN_USERNAME, full_name="Администратор", is_admin=True,
password_hash=hash_password(config.ADMIN_PASSWORD)))
db.commit()
log.warning("Создан администратор '%s' — смените пароль!", config.ADMIN_USERNAME)
push.ensure_vapid(db)
moved = logic.schedules_to_users(db)
if moved:
log.warning("Графики работы перенесены с должностей на сотрудников: %s", moved)
def _reindex_docs():
with SessionLocal() as db:
docs_index.rebuild(db)
async def scheduler_loop():
while True:
try:
with SessionLocal() as db:
created = logic.run_due_templates(db)
for o in created:
push.notify_assigned(db, o)
if created:
log.info("Создано нарядов по расписанию: %s", len(created))
except Exception:
log.exception("Ошибка планировщика")
try:
with SessionLocal() as db:
backup.auto_backup(db)
except Exception:
log.exception("Ошибка автоматического резервного копирования")
try:
with SessionLocal() as db:
if photo_cleanup.due(db): # старые фото — раз в сутки
photo_cleanup.run(db)
except Exception:
log.exception("Ошибка очистки старых фото")
try:
with SessionLocal() as db:
if docs_index.due(db): # индекс документации для AI — раз в сутки, в фоне
asyncio.get_running_loop().run_in_executor(None, _reindex_docs)
except Exception:
log.exception("Ошибка индекса документации")
await asyncio.sleep(config.SCHEDULER_INTERVAL)
@asynccontextmanager
async def lifespan(app: FastAPI):
bootstrap()
task = asyncio.create_task(scheduler_loop())
yield
task.cancel()
DESCRIPTION = """API сервиса чек-листов. Авторизация: `POST /api/v1/auth/login`, далее `Authorization: Bearer <access_token>`;
access-токен короткий, продлевается через `/auth/refresh`. Все даты — ISO 8601 со смещением. Подробности для разработчика
клиента — в docs/API.md."""
import jwt as _jwt # noqa: E402
async def _demo_context(request, call_next):
"""Какой БД нужен запрос: демо-токен → демо-БД (если она есть), иначе — боевая."""
flag = False
auth = request.headers.get("authorization", "")
if mode.State.active and auth.lower().startswith("bearer "):
try:
flag = _jwt.decode(auth[7:], config.SECRET_KEY, algorithms=["HS256"]).get("mode") == "demo"
except Exception:
flag = False
tok = mode.REQUEST_DEMO.set(flag)
try:
resp = await call_next(request)
if request.url.path.startswith("/static/") and not request.url.path.startswith("/static/icons/"):
resp.headers["Cache-Control"] = "no-cache" # после обновления сервера браузер сразу берёт новые скрипты (проверка по ETag)
return resp
finally:
mode.REQUEST_DEMO.reset(tok)
app = FastAPI(title="Сервис чек-листов", version="1.0.0", description=DESCRIPTION, lifespan=lifespan)
app.middleware("http")(_demo_context)
api = APIRouter()
api.include_router(catalog.router)
api.include_router(orders.router)
api.include_router(extras.router)
api.include_router(service_router.router)
api.include_router(reports.router)
api.include_router(demo_router.router)
api.include_router(inventory.router)
api.include_router(incidents.router)
api.include_router(tasks.router)
api.include_router(portal.router)
api.include_router(external.router)
api.include_router(history.router)
api.include_router(backups.router)
api.include_router(assistant_router.router)
api.include_router(reliability_router.router)
api.include_router(feedback_router.router)
api.include_router(docs_router.router)
@api.get("/health", tags=["service"])
def health():
return {"ok": True, "api_version": 1, "time": config.iso(config.now()), "demo": mode.is_demo_request()}
app.include_router(api, prefix="/api/v1")
app.include_router(api, prefix="/api", include_in_schema=False) # старый путь без версии (обратная совместимость)
app.mount("/static", StaticFiles(directory=STATIC), name="static")
@app.get("/sw.js")
def service_worker():
return FileResponse(STATIC / "sw.js", media_type="application/javascript",
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"})
@app.api_route("/app.apk", methods=["GET", "HEAD"], include_in_schema=False)
def android_apk():
"""Раздача APK Android-приложения.
Сначала берётся файл, загруженный вручную в DATA_DIR/apk/ (самый свежий *.apk) — так можно обновить приложение без
передеплоя. Если его нет — APK из образа (каталог release/ репозитория)."""
f = current_apk()
if not f:
raise HTTPException(404, "APK не загружен на сервер")
return FileResponse(f, media_type="application/vnd.android.package-archive", filename="checklists.apk")
def current_apk() -> Path | None:
for folder in (Path(config.DATA_DIR, "apk"), config.RELEASE_DIR):
files = sorted(folder.glob("*.apk"), key=lambda f: f.stat().st_mtime, reverse=True)
if files:
return files[0]
return None
@app.get("/api/v1/app/version", tags=["service"])
def app_version():
"""Какая версия Android-приложения лежит на сервере (без входа: приложение проверяет обновление при старте).
Версия берётся из имени файла checklists-<версия>.apk."""
f = current_apk()
if not f:
return {"available": False}
m = re.search(r"(\d+(?:\.\d+){1,3})", f.stem)
return {"available": True, "version": m.group(1) if m else None, "size": f.stat().st_size, "url": "/app.apk"}
@app.get("/m", include_in_schema=False)
@app.get("/m/", include_in_schema=False)
def mobile_app():
"""Мобильная версия (PWA с офлайном)."""
return FileResponse(STATIC / "m" / "index.html", headers={"Cache-Control": "no-cache"})
@app.get("/guide.pdf", include_in_schema=False)
def user_guide():
"""Руководство пользователя (PDF) — ссылка «Инструкция» в боковом меню."""
f = Path(__file__).resolve().parent.parent / "docs" / "user-guide.pdf"
if not f.is_file():
raise HTTPException(404, "Руководство не найдено на сервере")
return FileResponse(f, media_type="application/pdf", filename="Руководство пользователя.pdf",
content_disposition_type="inline")
PRESENTATION = {"pdf": ("application/pdf", "inline"),
"pptx": ("application/vnd.openxmlformats-officedocument.presentationml.presentation", "attachment")}
@app.get("/presentation.{ext}", include_in_schema=False)
def presentation(ext: str):
"""Презентация системы (PDF — посмотреть, PPTX — скачать): ссылки в боковом меню и на странице входа."""
if ext not in PRESENTATION:
raise HTTPException(404, "Нет такого файла")
f = Path(__file__).resolve().parent.parent / "docs" / "presentation" / f"presentation.{ext}"
if not f.is_file():
raise HTTPException(404, "Презентация не найдена на сервере")
media, disp = PRESENTATION[ext]
return FileResponse(f, media_type=media, filename=f"Презентация.{ext}", content_disposition_type=disp)
@app.get("/report", include_in_schema=False)
def public_report():
"""Публичная форма сообщения об инциденте (без входа)."""
return FileResponse(STATIC / "report.html", headers={"Cache-Control": "no-cache"})
@app.get("/")
def index():
return FileResponse(STATIC / "index.html")