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-токен короткий, продлевается через `/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")