Law/app/api/admin/uploads.py
2026-02-23 15:20:00 +03:00

245 lines
11 KiB
Python

from __future__ import annotations
import uuid
from typing import Tuple
from botocore.exceptions import ClientError
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.deps import require_role
from app.core.security import decode_jwt
from app.db.session import get_db
from app.models.admin_user import AdminUser
from app.models.attachment import Attachment
from app.models.message import Message
from app.models.request import Request
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client
from app.services.s3_storage import build_object_key, get_s3_storage
router = APIRouter()
def _max_file_bytes() -> int:
return int(settings.MAX_FILE_MB) * 1024 * 1024
def _max_case_bytes() -> int:
return int(settings.MAX_CASE_MB) * 1024 * 1024
def _validate_size_or_400(size_bytes: int) -> None:
if int(size_bytes or 0) <= 0:
raise HTTPException(status_code=400, detail="Некорректный размер файла")
if int(size_bytes) > _max_file_bytes():
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
def _uuid_or_400(raw: str | None, field_name: str) -> uuid.UUID:
if not raw:
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" обязательно')
try:
return uuid.UUID(str(raw))
except ValueError:
raise HTTPException(status_code=400, detail=f'Некорректный "{field_name}"')
def _ensure_case_capacity_or_400(request: Request, add_bytes: int) -> None:
current = int(request.total_attachments_bytes or 0)
if current + int(add_bytes) > _max_case_bytes():
raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)")
def _ensure_object_key_prefix_or_400(key: str, prefix: str) -> None:
if not str(key or "").startswith(prefix):
raise HTTPException(status_code=400, detail="Некорректный ключ объекта для выбранной сущности")
def _parse_scoped_object_key(key: str) -> Tuple[str, str]:
raw = str(key or "").strip()
if not raw or "/" not in raw:
return "", ""
first = raw.split("/", 1)[0].strip().lower()
parts = raw.split("/")
if len(parts) < 3:
return first, ""
return first, parts[1].strip()
def _uuid_or_none(raw: str) -> uuid.UUID | None:
try:
return uuid.UUID(str(raw))
except (TypeError, ValueError):
return None
@router.post("/init", response_model=UploadInitResponse)
def upload_init(
payload: UploadInitPayload,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
_validate_size_or_400(payload.size_bytes)
storage = get_s3_storage()
role = str(admin.get("role") or "")
actor_id = str(admin.get("sub") or "")
if payload.scope == UploadScope.REQUEST_ATTACHMENT:
request_uuid = _uuid_or_400(payload.request_id, "request_id")
request = db.get(Request, request_uuid)
if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_case_capacity_or_400(request, payload.size_bytes)
key = build_object_key(f"requests/{request.id}", payload.file_name)
return UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type))
if payload.scope == UploadScope.USER_AVATAR:
target_user_id = str(payload.user_id or actor_id)
target_uuid = _uuid_or_400(target_user_id, "user_id")
if role != "ADMIN" and str(target_uuid) != actor_id:
raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара")
user = db.get(AdminUser, target_uuid)
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
key = build_object_key(f"avatars/{user.id}", payload.file_name)
return UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type))
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
@router.post("/complete", response_model=UploadCompleteResponse)
def upload_complete(
payload: UploadCompletePayload,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
_validate_size_or_400(payload.size_bytes)
storage = get_s3_storage()
role = str(admin.get("role") or "")
actor_id = str(admin.get("sub") or "")
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
try:
head = storage.head_object(payload.key)
except ClientError:
raise HTTPException(status_code=400, detail="Файл не найден в хранилище")
actual_size = int(head.get("ContentLength") or payload.size_bytes)
if actual_size <= 0:
raise HTTPException(status_code=400, detail="Некорректный размер файла")
if actual_size > _max_file_bytes():
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
if payload.scope == UploadScope.REQUEST_ATTACHMENT:
request_uuid = _uuid_or_400(payload.request_id, "request_id")
request = db.get(Request, request_uuid)
if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/")
_ensure_case_capacity_or_400(request, actual_size)
message_uuid = None
if payload.message_id:
message_uuid = _uuid_or_400(payload.message_id, "message_id")
message = db.get(Message, message_uuid)
if message is None or message.request_id != request.id:
raise HTTPException(status_code=400, detail="Сообщение не найдено для указанной заявки")
if bool(message.immutable):
raise HTTPException(status_code=400, detail="Нельзя прикрепить файл к зафиксированному сообщению")
row = Attachment(
request_id=request.id,
message_id=message_uuid,
file_name=payload.file_name,
mime_type=payload.mime_type,
size_bytes=actual_size,
s3_key=payload.key,
responsible=responsible,
)
mark_unread_for_client(request, EVENT_ATTACHMENT)
notify_request_event(
db,
request=request,
event_type=NOTIFICATION_EVENT_ATTACHMENT,
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
actor_admin_user_id=admin.get("sub"),
body=f'Файл: {payload.file_name}',
responsible=responsible,
)
request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size
request.responsible = responsible
db.add(row)
db.add(request)
db.commit()
db.refresh(row)
return UploadCompleteResponse(status="ok", attachment_id=str(row.id))
if payload.scope == UploadScope.USER_AVATAR:
target_user_id = str(payload.user_id or actor_id)
target_uuid = _uuid_or_400(target_user_id, "user_id")
if role != "ADMIN" and str(target_uuid) != actor_id:
raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара")
user = db.get(AdminUser, target_uuid)
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
_ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/")
user.avatar_url = f"s3://{payload.key}"
user.responsible = responsible
db.add(user)
db.commit()
return UploadCompleteResponse(status="ok", avatar_url=user.avatar_url)
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
@router.get("/object/{object_key:path}")
def get_object_proxy(object_key: str, token: str = Query(...), db: Session = Depends(get_db)):
try:
claims = decode_jwt(token, settings.ADMIN_JWT_SECRET)
except Exception:
raise HTTPException(status_code=401, detail="Некорректный токен")
role = str(claims.get("role") or "").upper()
if role not in {"ADMIN", "LAWYER"}:
raise HTTPException(status_code=403, detail="Недостаточно прав")
key = str(object_key or "").strip()
if not key:
raise HTTPException(status_code=400, detail="Некорректный ключ объекта")
scope, scoped_id_raw = _parse_scoped_object_key(key)
if role == "LAWYER":
actor_id = _uuid_or_none(claims.get("sub"))
if actor_id is None:
raise HTTPException(status_code=401, detail="Некорректный токен")
scoped_uuid = _uuid_or_none(scoped_id_raw)
if scope == "avatars":
if scoped_uuid is None or scoped_uuid != actor_id:
raise HTTPException(status_code=403, detail="Недостаточно прав")
elif scope == "requests":
if scoped_uuid is None:
raise HTTPException(status_code=403, detail="Недостаточно прав")
# LAWYER can download files from own or unassigned requests only.
request = db.get(Request, scoped_uuid)
if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
assigned = str(request.assigned_lawyer_id or "").strip()
if assigned and assigned != str(actor_id):
raise HTTPException(status_code=403, detail="Недостаточно прав")
else:
raise HTTPException(status_code=403, detail="Недостаточно прав")
try:
obj = get_s3_storage().get_object(key)
except ClientError:
raise HTTPException(status_code=404, detail="Файл не найден")
body = obj["Body"]
content_length = obj.get("ContentLength")
media_type = obj.get("ContentType") or "application/octet-stream"
headers = {}
if content_length is not None:
headers["Content-Length"] = str(content_length)
return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers)