219 lines
9.6 KiB
Python
219 lines
9.6 KiB
Python
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")
|