"""Комментарии, фото-фиксация, настройки сервера, push-подписки.""" import mimetypes import shutil from datetime import datetime from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import FileResponse from sqlalchemy import select, func from sqlalchemy.orm import Session from .. import schemas as S, config, logic, photo_cleanup, push, settings_store as ss, sysinfo from ..db import get_db from ..models import Comment, Photo, Order, OrderItem, User, PushSubscription, DeviceToken from ..security import current_user, require_admin, require_portal_admin, is_manager, is_portal_admin from .catalog import commit from .orders import load, exec_guard, start, names, can_execute, to_out router = APIRouter() # ---------- комментарии ---------- def participants(o: Order) -> set[int]: return {x for x in (o.assignee_user_id, o.executor_id, o.reviewer_user_id) if x} @router.get("/orders/{id}/comments", response_model=list[S.CommentOut]) def list_comments(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)): load(db, id, u) nm = names(db)["user"] rows = db.scalars(select(Comment).where(Comment.order_id == id).order_by(Comment.id)).all() return [S.CommentOut(id=c.id, user_id=c.user_id, user_name=nm.get(c.user_id), text=c.text, created_at=c.created_at) for c in rows] @router.post("/orders/{id}/comments", response_model=S.CommentOut, status_code=201) def add_comment(id: int, data: S.CommentIn, db: Session = Depends(get_db), u: User = Depends(current_user)): o = load(db, id, u) c = Comment(order_id=id, user_id=u.id, text=data.text.strip()) db.add(c) commit(db) who = u.full_name or u.username push.notify(participants(o) - {u.id}, f"Комментарий к наряду №{o.id}", f"{who}: {c.text[:120]}", f"/#/order/{o.id}") return S.CommentOut(id=c.id, user_id=u.id, user_name=who, text=c.text, created_at=c.created_at) @router.delete("/comments/{id}", status_code=204) def delete_comment(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)): c = db.get(Comment, id) if not c: raise HTTPException(404, "Не найдено") if c.user_id != u.id and not is_manager(u): raise HTTPException(403, "Можно удалять только свои комментарии") db.delete(c) commit(db) # ---------- фото ---------- MAGIC = [(b"\xff\xd8\xff", "image/jpeg", ".jpg"), (b"\x89PNG\r\n\x1a\n", "image/png", ".png")] def sniff(data: bytes): for sig, ct, ext in MAGIC: if data.startswith(sig): return ct, ext if data[:4] == b"RIFF" and data[8:12] == b"WEBP": return "image/webp", ".webp" return None @router.post("/orders/{id}/items/{item_id}/photos", response_model=S.OrderOut, status_code=201) async def upload_photo(id: int, item_id: int, request: Request, db: Session = Depends(get_db), u: User = Depends(current_user)): """Тело запроса — сам файл (image/jpeg|png|webp), без multipart.""" o = load(db, id, u) exec_guard(o, u) item = next((i for i in o.items if i.id == item_id), None) if not item: raise HTTPException(404, "Пункт не найден") if item.photo == "none": raise HTTPException(400, "Для этого пункта фото не предусмотрено") key = (request.headers.get("idempotency-key") or "")[:64] or None if key and any(p.client_key == key for p in item.photos): return to_out(o, u, names(db), with_items=True) # повторная отправка той же фотографии limit = int(ss.get(db, "max_photo_mb")) * 1024 * 1024 data = b"" async for chunk in request.stream(): data += chunk if len(data) > limit: raise HTTPException(413, f"Файл больше {limit // 1024 // 1024} МБ") kind = sniff(data) if not kind: raise HTTPException(415, "Допустимы JPEG, PNG, WebP") ct, ext = kind root = ss.photo_root(db) rel = Path("keep" if item.photo_keep else "") / f"{config.now():%Y/%m}" / (ss.new_name() + ext) # keep/ — не удаляются очисткой (root / rel).parent.mkdir(parents=True, exist_ok=True) (root / rel).write_bytes(data) db.add(Photo(order_id=o.id, item_id=item.id, path=rel.as_posix(), content_type=ct, size=len(data), user_id=u.id, client_key=key)) start(o, u) commit(db) db.refresh(item) return to_out(o, u, names(db), with_items=True) def photo_for(db: Session, id: int, u: User) -> Photo: p = db.get(Photo, id) if not p: raise HTTPException(404, "Фото не найдено") load(db, p.order_id, u) # проверка доступа к наряду return p @router.get("/photos/{id}") def get_photo(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)): p = photo_for(db, id, u) f = ss.photo_root(db) / p.path if not f.is_file(): raise HTTPException(404, "Файл отсутствует на сервере") return FileResponse(f, media_type=p.content_type, headers={"Cache-Control": "private, max-age=86400"}) @router.delete("/photos/{id}", response_model=S.OrderOut) def delete_photo(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)): p = photo_for(db, id, u) o = load(db, p.order_id, u) if not (is_manager(u) or (p.user_id == u.id and can_execute(u, o))): raise HTTPException(403, "Нельзя удалить это фото") f = ss.photo_root(db) / p.path db.delete(p) commit(db) f.unlink(missing_ok=True) return to_out(o, u, names(db), with_items=True) # ---------- настройки сервера (только админ) ---------- def dir_stats(root: Path) -> dict: files = [x for x in root.rglob("*") if x.is_file()] if root.exists() else [] free = shutil.disk_usage(root if root.exists() else Path(".")).free return {"files": len(files), "bytes": sum(x.stat().st_size for x in files), "disk_free": free} def keep_stats(root: Path) -> dict: a, b = dir_stats(root / "keep"), dir_stats(root / "media" / "tasks" / "keep") return {"files": a["files"] + b["files"], "bytes": a["bytes"] + b["bytes"]} @router.get("/photo-cleanup") def cleanup_preview(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)): """Сколько фото удалит очистка сейчас (ничего не удаляет).""" return {**photo_cleanup.run(db, dry=True), "last_run": ss.get(db, "photo_cleanup_at") or None} @router.post("/photo-cleanup") def cleanup_now(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)): return photo_cleanup.run(db) @router.get("/server/monitor") def monitor(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)): """Мониторинг сервера: загрузка процессора, память, место на дисках (данные портала и фото).""" return {**sysinfo.snapshot({"Данные портала": Path(config.DATA_DIR), "Фото": ss.photo_root(db)}), "now": config.iso(config.now())} def settings_payload(db: Session) -> dict: return {**ss.all_editable(db), "photo_stats": dir_stats(ss.photo_root(db)), "photo_keep_stats": keep_stats(ss.photo_root(db)), "vapid_public": push.public_key(db), "now": config.iso(config.now()), "fcm_enabled": push.fcm_enabled(db), "timezone": str(config.TZ)} @router.get("/settings") def get_settings(request: Request, db: Session = Depends(get_db), u: User = Depends(require_admin)): """Вместе с настройками отдаёт, каким сервер видит адрес клиента: по нему проверяют цепочку прокси (NPM). Администратор без прав администратора портала получает только то, что нужно мастеру первичной настройки.""" if not is_portal_admin(u): return {k: ss.get(db, k) for k in ("setup_done", "public_incidents", "crane_map", "reliability")} | {"limited": True} return {**settings_payload(db), "client_ip": request.client.host if request.client else None, "forwarded_for": request.headers.get("x-forwarded-for", "")} @router.put("/settings") def put_settings(data: S.SettingsIn, db: Session = Depends(get_db), u: User = Depends(require_admin)): ch = data.model_dump(exclude_none=True) if not is_portal_admin(u) and set(ch) - {"setup_done"}: # мастеру можно отмечать шаги, остальное — администратору портала raise HTTPException(403, "Настройки сервера доступны только администраторам портала") if ch.get("fcm_credentials_path"): if not Path(ch["fcm_credentials_path"]).is_file(): raise HTTPException(422, "Файл ключа сервисного аккаунта Firebase не найден на сервере") if "photo_dir" in ch: d = Path(ch["photo_dir"].strip()) try: d.mkdir(parents=True, exist_ok=True) probe = d / ".write_test" probe.write_text("ok") probe.unlink() except OSError as e: raise HTTPException(422, f"Каталог недоступен для записи: {e}") ch["photo_dir"] = str(d) for k, v in ch.items(): ss.set_(db, k, ("1" if v else "0") if isinstance(v, bool) else str(v)) commit(db) return settings_payload(db) # ---------- push ---------- @router.get("/push/key") def push_key(db: Session = Depends(get_db), _: User = Depends(current_user)): return {"key": push.public_key(db)} @router.post("/push/subscribe", status_code=204) def push_subscribe(data: S.PushSubIn, db: Session = Depends(get_db), u: User = Depends(current_user)): if not all(isinstance(data.keys.get(k), str) and 0 < len(data.keys[k]) <= 200 for k in ("p256dh", "auth")): raise HTTPException(422, "Нужны ключи p256dh и auth") sub = db.scalar(select(PushSubscription).where(PushSubscription.endpoint == data.endpoint)) if sub: sub.user_id, sub.p256dh, sub.auth = u.id, data.keys["p256dh"], data.keys["auth"] else: db.add(PushSubscription(user_id=u.id, endpoint=data.endpoint, p256dh=data.keys["p256dh"], auth=data.keys["auth"])) commit(db) @router.post("/push/unsubscribe", status_code=204) def push_unsubscribe(data: S.PushSubIn, db: Session = Depends(get_db), u: User = Depends(current_user)): sub = db.scalar(select(PushSubscription).where(PushSubscription.endpoint == data.endpoint, PushSubscription.user_id == u.id)) if sub: db.delete(sub) commit(db) @router.post("/push/test", status_code=204) def push_test(u: User = Depends(current_user)): push.notify([u.id], "Проверка уведомлений", "Если вы это видите — всё работает", "/") @router.post("/push/device", status_code=204, tags=["push"]) def register_device(data: S.DeviceIn, db: Session = Depends(get_db), u: User = Depends(current_user)): """Android: регистрация FCM-токена устройства (вызывать при входе и при onNewToken).""" d = db.scalar(select(DeviceToken).where(DeviceToken.token == data.token)) if d: d.user_id, d.platform = u.id, data.platform else: db.add(DeviceToken(user_id=u.id, token=data.token, platform=data.platform)) commit(db) @router.delete("/push/device", status_code=204, tags=["push"]) def unregister_device(data: S.DeviceIn, db: Session = Depends(get_db), u: User = Depends(current_user)): """Android: вызывать при выходе из аккаунта.""" d = db.scalar(select(DeviceToken).where(DeviceToken.token == data.token, DeviceToken.user_id == u.id)) if d: db.delete(d) commit(db)