Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
+218
@@ -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")
|
||||
Reference in New Issue
Block a user