Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit 67e411eea8
246 changed files with 23152 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
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, logic, mode, 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
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)
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("Ошибка автоматического резервного копирования")
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.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")