Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit 67e411eea8
246 changed files with 23152 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
"""Комментарии, фото-фиксация, настройки сервера, 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)