236 lines
11 KiB
Python
236 lines
11 KiB
Python
"""Комментарии, фото-фиксация, настройки сервера, 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, push, settings_store as ss
|
|
from ..db import get_db
|
|
from ..models import Comment, Photo, Order, OrderItem, User, PushSubscription, DeviceToken
|
|
from ..security import current_user, require_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(f"{config.now():%Y/%m}") / (ss.new_name() + ext)
|
|
(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 settings_payload(db: Session) -> dict:
|
|
return {**ss.all_editable(db), "photo_stats": dir_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)
|