From 46234f6d511f4aa9f7f19daad29c017d2649d2cd Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:50:17 +0300 Subject: [PATCH] fix user UI 9 --- app/api/admin/uploads.py | 25 ++++ app/api/public/featured_staff.py | 66 ++++++++- app/api/public/uploads.py | 25 ++++ app/web/admin.js | 157 ++++++++++++-------- app/web/admin/hooks/useAdminApi.js | 4 +- app/web/admin/hooks/useRequestWorkspace.js | 165 +++++++++++++-------- app/web/client.js | 128 +++++++++++----- app/web/client.jsx | 138 ++++++++++++----- tests/test_featured_staff_public.py | 152 +++++++++++++++++++ tests/test_uploads_s3.py | 119 +++++++++++++++ 10 files changed, 777 insertions(+), 202 deletions(-) create mode 100644 tests/test_featured_staff_public.py diff --git a/app/api/admin/uploads.py b/app/api/admin/uploads.py index 27d0003..2a68502 100644 --- a/app/api/admin/uploads.py +++ b/app/api/admin/uploads.py @@ -285,6 +285,31 @@ def upload_complete( if request is None: raise HTTPException(status_code=404, detail="Заявка не найдена") _ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/") + existing_row = ( + db.query(Attachment) + .filter(Attachment.request_id == request.id, Attachment.s3_key == payload.key) + .first() + ) + if existing_row is not None: + record_file_security_event( + db, + actor_role=role, + actor_subject=actor_id, + actor_ip=actor_ip, + action="UPLOAD_COMPLETE", + scope=scope_name, + allowed=True, + object_key=payload.key, + request_id=request.id, + details={ + "mime_type": existing_row.mime_type, + "size_bytes": int(existing_row.size_bytes or 0), + "idempotent_replay": True, + }, + responsible=responsible, + ) + db.commit() + return UploadCompleteResponse(status="ok", attachment_id=str(existing_row.id)) _ensure_case_capacity_or_400(request, actual_size) message_uuid = None diff --git a/app/api/public/featured_staff.py b/app/api/public/featured_staff.py index 24d533e..32b51f4 100644 --- a/app/api/public/featured_staff.py +++ b/app/api/public/featured_staff.py @@ -1,6 +1,10 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Query +from uuid import UUID + +from botocore.exceptions import ClientError +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse from sqlalchemy import and_ from sqlalchemy.orm import Session @@ -8,10 +12,15 @@ from app.db.session import get_db from app.models.admin_user import AdminUser from app.models.landing_featured_staff import LandingFeaturedStaff from app.models.topic import Topic +from app.services.s3_storage import get_s3_storage router = APIRouter() +def _featured_avatar_proxy_path(admin_user_id: str) -> str: + return "/api/public/featured-staff/avatar/" + str(admin_user_id) + + @router.get("") def list_featured_staff( limit: int = Query(20, ge=1, le=100), @@ -46,6 +55,10 @@ def list_featured_staff( role_code = str(user.role or "").upper() role_label = "Администратор" if role_code == "ADMIN" else "Юрист" primary_topic_code = str(user.primary_topic_code or "").strip() or None + raw_avatar_url = str(user.avatar_url or "").strip() + avatar_url = raw_avatar_url + if raw_avatar_url.startswith("s3://"): + avatar_url = _featured_avatar_proxy_path(str(user.id)) result.append( { "id": str(slot.id), @@ -53,7 +66,7 @@ def list_featured_staff( "name": user.name, "role": role_code, "role_label": role_label, - "avatar_url": user.avatar_url, + "avatar_url": avatar_url, "caption": str(slot.caption or "").strip() or None, "pinned": bool(slot.pinned), "sort_order": int(slot.sort_order or 0), @@ -62,3 +75,52 @@ def list_featured_staff( } ) return {"items": result, "total": len(result)} + + +@router.get("/avatar/{admin_user_id}") +def get_featured_staff_avatar( + admin_user_id: str, + db: Session = Depends(get_db), +): + try: + user_uuid = UUID(str(admin_user_id)) + except ValueError: + raise HTTPException(status_code=400, detail="Некорректный id пользователя") + + row = ( + db.query(AdminUser.avatar_url) + .join(LandingFeaturedStaff, LandingFeaturedStaff.admin_user_id == AdminUser.id) + .filter( + LandingFeaturedStaff.enabled.is_(True), + AdminUser.id == user_uuid, + AdminUser.is_active.is_(True), + AdminUser.role.in_(("ADMIN", "LAWYER")), + AdminUser.avatar_url.is_not(None), + and_(AdminUser.avatar_url != ""), + ) + .first() + ) + if row is None: + raise HTTPException(status_code=404, detail="Аватар не найден") + + raw_avatar_url = str(row[0] or "").strip() + if not raw_avatar_url.startswith("s3://"): + raise HTTPException(status_code=404, detail="Аватар не найден") + key = raw_avatar_url[len("s3://") :].strip() + if not key.startswith("avatars/" + str(user_uuid) + "/"): + raise HTTPException(status_code=404, detail="Аватар не найден") + + try: + obj = get_s3_storage().get_object(key) + except ClientError: + raise HTTPException(status_code=404, detail="Аватар не найден") + + body = obj.get("Body") + if body is None or not hasattr(body, "iter_chunks"): + raise HTTPException(status_code=500, detail="Не удалось открыть аватар") + media_type = str(obj.get("ContentType") or "application/octet-stream") + content_length = obj.get("ContentLength") + 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) diff --git a/app/api/public/uploads.py b/app/api/public/uploads.py index 5b79de6..b95dbcd 100644 --- a/app/api/public/uploads.py +++ b/app/api/public/uploads.py @@ -185,6 +185,31 @@ def upload_complete( raise HTTPException(status_code=404, detail="Заявка не найдена") _ensure_public_request_access_or_403(request, session) _ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/") + existing_row = ( + db.query(Attachment) + .filter(Attachment.request_id == request.id, Attachment.s3_key == payload.key) + .first() + ) + if existing_row is not None: + record_file_security_event( + db, + actor_role="CLIENT", + actor_subject=actor_subject, + actor_ip=actor_ip, + action="UPLOAD_COMPLETE", + scope=scope_name, + allowed=True, + object_key=payload.key, + request_id=request.id, + details={ + "mime_type": existing_row.mime_type, + "size_bytes": int(existing_row.size_bytes or 0), + "idempotent_replay": True, + }, + responsible="Клиент", + ) + db.commit() + return UploadCompleteResponse(status="ok", attachment_id=str(existing_row.id)) storage = get_s3_storage() try: diff --git a/app/web/admin.js b/app/web/admin.js index 1c5eb50..2bb46a4 100644 --- a/app/web/admin.js +++ b/app/web/admin.js @@ -5764,7 +5764,9 @@ } if (!response.ok) { const message = payload && (payload.detail || payload.error || payload.raw) || "HTTP " + response.status; - throw new Error(translateApiError(String(message))); + const error = new Error(translateApiError(String(message))); + error.httpStatus = Number(response.status || 0); + throw error; } return payload; }, @@ -6025,6 +6027,7 @@ bank_account: "40702810501860000582", bank_corr_account: "30101810200000000593" }); + var UPLOAD_MAX_ATTEMPTS = 4; async function buildStorageUploadError(response, fallbackMessage) { const base = String(fallbackMessage || "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0444\u0430\u0439\u043B \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435"); const status = Number((response == null ? void 0 : response.status) || 0); @@ -6041,6 +6044,22 @@ if (details) parts.push(details); return parts.length ? base + " (" + parts.join("; ") + ")" : base; } + function wait(ms) { + return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0))); + } + function nextUploadRetryDelayMs(attempt) { + const base = Math.min(1200 * Math.pow(2, Math.max(0, Number(attempt || 1) - 1)), 7e3); + const jitter = Math.floor(Math.random() * 250); + return base + jitter; + } + function isRetryableUploadError(error) { + const status = Number((error == null ? void 0 : error.httpStatus) || (error == null ? void 0 : error.status) || 0); + if ([408, 425, 429, 500, 502, 503, 504].includes(status)) return true; + if (status > 0) return false; + const message = String((error == null ? void 0 : error.message) || "").toLowerCase(); + if (!message) return true; + return message.includes("networkerror") || message.includes("failed to fetch") || message.includes("load failed") || message.includes("network request failed") || message.includes("timeout"); + } function useRequestWorkspace(options) { const { useCallback, useRef, useState } = React; const opts = options || {}; @@ -6086,6 +6105,70 @@ const clearRequestModalFiles = useCallback(() => { setRequestModal((prev) => ({ ...prev, selectedFiles: [] })); }, []); + const uploadRequestAttachmentWithRetry = useCallback( + async ({ requestId, file, messageId }) => { + if (!api) throw new Error("API \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D"); + const targetRequestId = String(requestId || "").trim(); + if (!targetRequestId) throw new Error("\u041D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D\u0430 \u0437\u0430\u044F\u0432\u043A\u0430"); + if (!file) throw new Error("\u0424\u0430\u0439\u043B \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D"); + const mimeType = String(file.type || "application/octet-stream"); + const runUploadStepWithRetry = async (label, action) => { + let lastError = null; + let attemptsUsed = 0; + for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt += 1) { + attemptsUsed = attempt; + try { + return await action(attempt); + } catch (error) { + lastError = error; + const canRetry = attempt < UPLOAD_MAX_ATTEMPTS && isRetryableUploadError(error); + if (!canRetry) break; + await wait(nextUploadRetryDelayMs(attempt)); + } + } + const reason = String((lastError == null ? void 0 : lastError.message) || "\u041E\u0448\u0438\u0431\u043A\u0430 \u0441\u0435\u0442\u0438"); + throw new Error(label + ": " + reason + " (\u043F\u043E\u043F\u044B\u0442\u043E\u043A: " + attemptsUsed + ")"); + }; + const init = await runUploadStepWithRetry("\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043D\u0430\u0447\u0430\u0442\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0444\u0430\u0439\u043B\u0430", async () => { + return api("/api/admin/uploads/init", { + method: "POST", + body: { + file_name: file.name, + mime_type: mimeType, + size_bytes: file.size, + scope: "REQUEST_ATTACHMENT", + request_id: targetRequestId + } + }); + }); + await runUploadStepWithRetry("\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0444\u0430\u0439\u043B \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435", async () => { + const putResp = await fetch(init.presigned_url, { + method: "PUT", + headers: { "Content-Type": mimeType }, + body: file + }); + if (putResp.ok) return null; + const error = new Error(await buildStorageUploadError(putResp, "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0444\u0430\u0439\u043B \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435")); + error.httpStatus = Number(putResp.status || 0); + throw error; + }); + return runUploadStepWithRetry("\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0444\u0430\u0439\u043B\u0430", async () => { + return api("/api/admin/uploads/complete", { + method: "POST", + body: { + key: init.key, + file_name: file.name, + mime_type: mimeType, + size_bytes: file.size, + scope: "REQUEST_ATTACHMENT", + request_id: targetRequestId, + message_id: messageId || null + } + }); + }); + }, + [api] + ); const loadRequestModalData = useCallback( async (requestId, loadOptions) => { if (!api || !requestId) return; @@ -6261,35 +6344,7 @@ messageId = String((message == null ? void 0 : message.id) || "").trim() || null; } for (const file of files) { - const mimeType = String(file.type || "application/octet-stream"); - const init = await api("/api/admin/uploads/init", { - method: "POST", - body: { - file_name: file.name, - mime_type: mimeType, - size_bytes: file.size, - scope: "REQUEST_ATTACHMENT", - request_id: requestId - } - }); - const putResp = await fetch(init.presigned_url, { - method: "PUT", - headers: { "Content-Type": mimeType }, - body: file - }); - if (!putResp.ok) throw new Error(await buildStorageUploadError(putResp, "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0444\u0430\u0439\u043B \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435")); - await api("/api/admin/uploads/complete", { - method: "POST", - body: { - key: init.key, - file_name: file.name, - mime_type: mimeType, - size_bytes: file.size, - scope: "REQUEST_ATTACHMENT", - request_id: requestId, - message_id: messageId - } - }); + await uploadRequestAttachmentWithRetry({ requestId, file, messageId }); } setRequestModal((prev) => ({ ...prev, messageDraft: "", selectedFiles: [], fileUploading: false })); const successMessage = body && files.length ? "\u0421\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0435 \u0438 \u0444\u0430\u0439\u043B\u044B \u043E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u044B" : files.length ? "\u0424\u0430\u0439\u043B\u044B \u043E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u044B" : "\u0421\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0435 \u043E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u043E"; @@ -6300,7 +6355,15 @@ if (typeof setStatus === "function") setStatus("requestModal", "\u041E\u0448\u0438\u0431\u043A\u0430 \u043E\u0442\u043F\u0440\u0430\u0432\u043A\u0438: " + error.message, "error"); } }, - [api, loadRequestModalData, requestModal.messageDraft, requestModal.requestId, requestModal.selectedFiles, setStatus] + [ + api, + loadRequestModalData, + requestModal.messageDraft, + requestModal.requestId, + requestModal.selectedFiles, + setStatus, + uploadRequestAttachmentWithRetry + ] ); const loadRequestDataTemplates = useCallback( async (documentName) => { @@ -6420,41 +6483,13 @@ messageId = String((message == null ? void 0 : message.id) || "").trim() || null; } for (const file of attachedFiles) { - const mimeType = String(file.type || "application/octet-stream"); - const init = await api("/api/admin/uploads/init", { - method: "POST", - body: { - file_name: file.name, - mime_type: mimeType, - size_bytes: file.size, - scope: "REQUEST_ATTACHMENT", - request_id: targetRequestId - } - }); - const putResp = await fetch(init.presigned_url, { - method: "PUT", - headers: { "Content-Type": mimeType }, - body: file - }); - if (!putResp.ok) throw new Error(await buildStorageUploadError(putResp, "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0444\u0430\u0439\u043B \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435")); - await api("/api/admin/uploads/complete", { - method: "POST", - body: { - key: init.key, - file_name: file.name, - mime_type: mimeType, - size_bytes: file.size, - scope: "REQUEST_ATTACHMENT", - request_id: targetRequestId, - message_id: messageId - } - }); + await uploadRequestAttachmentWithRetry({ requestId: targetRequestId, file, messageId }); } if (typeof setStatus === "function") setStatus("requestModal", "\u0421\u0442\u0430\u0442\u0443\u0441 \u0437\u0430\u044F\u0432\u043A\u0438 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D", "ok"); await loadRequestModalData(targetRequestId, { showLoading: false }); return result; }, - [api, loadRequestModalData, requestModal.availableStatuses, requestModal.requestId, setStatus] + [api, loadRequestModalData, requestModal.availableStatuses, requestModal.requestId, setStatus, uploadRequestAttachmentWithRetry] ); const issueRequestInvoice = useCallback( async ({ requestId, amount, serviceDescription, payerDisplayName } = {}) => { diff --git a/app/web/admin/hooks/useAdminApi.js b/app/web/admin/hooks/useAdminApi.js index 86221a6..60b089a 100644 --- a/app/web/admin/hooks/useAdminApi.js +++ b/app/web/admin/hooks/useAdminApi.js @@ -30,7 +30,9 @@ export function useAdminApi(token) { if (!response.ok) { const message = (payload && (payload.detail || payload.error || payload.raw)) || "HTTP " + response.status; - throw new Error(translateApiError(String(message))); + const error = new Error(translateApiError(String(message))); + error.httpStatus = Number(response.status || 0); + throw error; } return payload; diff --git a/app/web/admin/hooks/useRequestWorkspace.js b/app/web/admin/hooks/useRequestWorkspace.js index bb11dc6..c5459a4 100644 --- a/app/web/admin/hooks/useRequestWorkspace.js +++ b/app/web/admin/hooks/useRequestWorkspace.js @@ -11,6 +11,7 @@ const DEFAULT_INVOICE_REQUISITES = Object.freeze({ bank_account: "40702810501860000582", bank_corr_account: "30101810200000000593", }); +const UPLOAD_MAX_ATTEMPTS = 4; async function buildStorageUploadError(response, fallbackMessage) { const base = String(fallbackMessage || "Не удалось загрузить файл в хранилище"); @@ -29,6 +30,31 @@ async function buildStorageUploadError(response, fallbackMessage) { return parts.length ? base + " (" + parts.join("; ") + ")" : base; } +function wait(ms) { + return new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0))); +} + +function nextUploadRetryDelayMs(attempt) { + const base = Math.min(1200 * Math.pow(2, Math.max(0, Number(attempt || 1) - 1)), 7000); + const jitter = Math.floor(Math.random() * 250); + return base + jitter; +} + +function isRetryableUploadError(error) { + const status = Number(error?.httpStatus || error?.status || 0); + if ([408, 425, 429, 500, 502, 503, 504].includes(status)) return true; + if (status > 0) return false; + const message = String(error?.message || "").toLowerCase(); + if (!message) return true; + return ( + message.includes("networkerror") || + message.includes("failed to fetch") || + message.includes("load failed") || + message.includes("network request failed") || + message.includes("timeout") + ); +} + export function useRequestWorkspace(options) { const { useCallback, useRef, useState } = React; const opts = options || {}; @@ -85,6 +111,73 @@ export function useRequestWorkspace(options) { setRequestModal((prev) => ({ ...prev, selectedFiles: [] })); }, []); + const uploadRequestAttachmentWithRetry = useCallback( + async ({ requestId, file, messageId }) => { + if (!api) throw new Error("API недоступен"); + const targetRequestId = String(requestId || "").trim(); + if (!targetRequestId) throw new Error("Не выбрана заявка"); + if (!file) throw new Error("Файл не выбран"); + const mimeType = String(file.type || "application/octet-stream"); + + const runUploadStepWithRetry = async (label, action) => { + let lastError = null; + let attemptsUsed = 0; + for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt += 1) { + attemptsUsed = attempt; + try { + return await action(attempt); + } catch (error) { + lastError = error; + const canRetry = attempt < UPLOAD_MAX_ATTEMPTS && isRetryableUploadError(error); + if (!canRetry) break; + await wait(nextUploadRetryDelayMs(attempt)); + } + } + const reason = String(lastError?.message || "Ошибка сети"); + throw new Error(label + ": " + reason + " (попыток: " + attemptsUsed + ")"); + }; + + const init = await runUploadStepWithRetry("Не удалось начать загрузку файла", async () => { + return api("/api/admin/uploads/init", { + method: "POST", + body: { + file_name: file.name, + mime_type: mimeType, + size_bytes: file.size, + scope: "REQUEST_ATTACHMENT", + request_id: targetRequestId, + }, + }); + }); + await runUploadStepWithRetry("Не удалось загрузить файл в хранилище", async () => { + const putResp = await fetch(init.presigned_url, { + method: "PUT", + headers: { "Content-Type": mimeType }, + body: file, + }); + if (putResp.ok) return null; + const error = new Error(await buildStorageUploadError(putResp, "Не удалось загрузить файл в хранилище")); + error.httpStatus = Number(putResp.status || 0); + throw error; + }); + return runUploadStepWithRetry("Не удалось завершить загрузку файла", async () => { + return api("/api/admin/uploads/complete", { + method: "POST", + body: { + key: init.key, + file_name: file.name, + mime_type: mimeType, + size_bytes: file.size, + scope: "REQUEST_ATTACHMENT", + request_id: targetRequestId, + message_id: messageId || null, + }, + }); + }); + }, + [api] + ); + const loadRequestModalData = useCallback( async (requestId, loadOptions) => { if (!api || !requestId) return; @@ -264,35 +357,7 @@ export function useRequestWorkspace(options) { } for (const file of files) { - const mimeType = String(file.type || "application/octet-stream"); - const init = await api("/api/admin/uploads/init", { - method: "POST", - body: { - file_name: file.name, - mime_type: mimeType, - size_bytes: file.size, - scope: "REQUEST_ATTACHMENT", - request_id: requestId, - }, - }); - const putResp = await fetch(init.presigned_url, { - method: "PUT", - headers: { "Content-Type": mimeType }, - body: file, - }); - if (!putResp.ok) throw new Error(await buildStorageUploadError(putResp, "Не удалось загрузить файл в хранилище")); - await api("/api/admin/uploads/complete", { - method: "POST", - body: { - key: init.key, - file_name: file.name, - mime_type: mimeType, - size_bytes: file.size, - scope: "REQUEST_ATTACHMENT", - request_id: requestId, - message_id: messageId, - }, - }); + await uploadRequestAttachmentWithRetry({ requestId, file, messageId }); } setRequestModal((prev) => ({ ...prev, messageDraft: "", selectedFiles: [], fileUploading: false })); @@ -304,7 +369,15 @@ export function useRequestWorkspace(options) { if (typeof setStatus === "function") setStatus("requestModal", "Ошибка отправки: " + error.message, "error"); } }, - [api, loadRequestModalData, requestModal.messageDraft, requestModal.requestId, requestModal.selectedFiles, setStatus] + [ + api, + loadRequestModalData, + requestModal.messageDraft, + requestModal.requestId, + requestModal.selectedFiles, + setStatus, + uploadRequestAttachmentWithRetry, + ] ); const loadRequestDataTemplates = useCallback( @@ -439,42 +512,14 @@ export function useRequestWorkspace(options) { messageId = String(message?.id || "").trim() || null; } for (const file of attachedFiles) { - const mimeType = String(file.type || "application/octet-stream"); - const init = await api("/api/admin/uploads/init", { - method: "POST", - body: { - file_name: file.name, - mime_type: mimeType, - size_bytes: file.size, - scope: "REQUEST_ATTACHMENT", - request_id: targetRequestId, - }, - }); - const putResp = await fetch(init.presigned_url, { - method: "PUT", - headers: { "Content-Type": mimeType }, - body: file, - }); - if (!putResp.ok) throw new Error(await buildStorageUploadError(putResp, "Не удалось загрузить файл в хранилище")); - await api("/api/admin/uploads/complete", { - method: "POST", - body: { - key: init.key, - file_name: file.name, - mime_type: mimeType, - size_bytes: file.size, - scope: "REQUEST_ATTACHMENT", - request_id: targetRequestId, - message_id: messageId, - }, - }); + await uploadRequestAttachmentWithRetry({ requestId: targetRequestId, file, messageId }); } if (typeof setStatus === "function") setStatus("requestModal", "Статус заявки обновлен", "ok"); await loadRequestModalData(targetRequestId, { showLoading: false }); return result; }, - [api, loadRequestModalData, requestModal.availableStatuses, requestModal.requestId, setStatus] + [api, loadRequestModalData, requestModal.availableStatuses, requestModal.requestId, setStatus, uploadRequestAttachmentWithRetry] ); const issueRequestInvoice = useCallback( diff --git a/app/web/client.js b/app/web/client.js index f5bd7ef..5a5bd1f 100644 --- a/app/web/client.js +++ b/app/web/client.js @@ -2480,6 +2480,7 @@ } function App() { var _a, _b; + const UPLOAD_MAX_ATTEMPTS = 4; const [requestModal, setRequestModal] = useState(createRequestModalState()); const [requestsList, setRequestsList] = useState([]); const [activeTrack, setActiveTrack] = useState(""); @@ -2513,7 +2514,11 @@ window.location.href = "/"; throw new Error("\u041D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0430"); } - if (!response.ok) throw new Error(apiError(data, fallbackMessage || "\u041E\u0448\u0438\u0431\u043A\u0430 \u0437\u0430\u043F\u0440\u043E\u0441\u0430")); + if (!response.ok) { + const error = new Error(apiError(data, fallbackMessage || "\u041E\u0448\u0438\u0431\u043A\u0430 \u0437\u0430\u043F\u0440\u043E\u0441\u0430")); + error.httpStatus = Number(response.status || 0); + throw error; + } return data; }, []); const buildStorageUploadError = useCallback(async (response, fallbackMessage) => { @@ -2532,50 +2537,95 @@ if (details) parts.push(details); return parts.length ? base + " (" + parts.join("; ") + ")" : base; }, []); + const wait = useCallback(async (ms) => { + await new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0))); + }, []); + const nextUploadRetryDelayMs = useCallback((attempt) => { + const base = Math.min(1200 * Math.pow(2, Math.max(0, Number(attempt || 1) - 1)), 7e3); + const jitter = Math.floor(Math.random() * 250); + return base + jitter; + }, []); + const isRetryableUploadError = useCallback((error) => { + const status2 = Number((error == null ? void 0 : error.httpStatus) || (error == null ? void 0 : error.status) || 0); + if ([408, 425, 429, 500, 502, 503, 504].includes(status2)) return true; + if (status2 > 0) return false; + const message = String((error == null ? void 0 : error.message) || "").toLowerCase(); + if (!message) return true; + return message.includes("networkerror") || message.includes("failed to fetch") || message.includes("load failed") || message.includes("network request failed") || message.includes("timeout"); + }, []); + const runUploadStepWithRetry = useCallback( + async (label, action) => { + let lastError = null; + let attemptsUsed = 0; + for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt += 1) { + attemptsUsed = attempt; + try { + return await action(attempt); + } catch (error) { + lastError = error; + const canRetry = attempt < UPLOAD_MAX_ATTEMPTS && isRetryableUploadError(error); + if (!canRetry) break; + await wait(nextUploadRetryDelayMs(attempt)); + } + } + const reason = String((lastError == null ? void 0 : lastError.message) || "\u041E\u0448\u0438\u0431\u043A\u0430 \u0441\u0435\u0442\u0438"); + throw new Error(label + ": " + reason + " (\u043F\u043E\u043F\u044B\u0442\u043E\u043A: " + attemptsUsed + ")"); + }, + [UPLOAD_MAX_ATTEMPTS, isRetryableUploadError, nextUploadRetryDelayMs, wait] + ); const uploadPublicRequestAttachment = useCallback(async (file, extra = {}) => { const requestId = String(requestModal.requestId || "").trim(); if (!requestId) throw new Error("\u041D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D\u0430 \u0437\u0430\u044F\u0432\u043A\u0430"); const mimeType = String((file == null ? void 0 : file.type) || "application/octet-stream"); - const initData = await apiJson( - "/api/public/uploads/init", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - file_name: file.name, - mime_type: mimeType, - size_bytes: file.size, - scope: "REQUEST_ATTACHMENT", - request_id: requestId - }) - }, - "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043D\u0430\u0447\u0430\u0442\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0444\u0430\u0439\u043B\u0430" - ); - const putResponse = await fetch(initData.presigned_url, { - method: "PUT", - headers: { "Content-Type": mimeType }, - body: file + const initData = await runUploadStepWithRetry("\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043D\u0430\u0447\u0430\u0442\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0444\u0430\u0439\u043B\u0430", async () => { + return apiJson( + "/api/public/uploads/init", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file_name: file.name, + mime_type: mimeType, + size_bytes: file.size, + scope: "REQUEST_ATTACHMENT", + request_id: requestId + }) + }, + "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043D\u0430\u0447\u0430\u0442\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0444\u0430\u0439\u043B\u0430" + ); + }); + await runUploadStepWithRetry("\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0444\u0430\u0439\u043B\u0430 \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435", async () => { + const putResponse = await fetch(initData.presigned_url, { + method: "PUT", + headers: { "Content-Type": mimeType }, + body: file + }); + if (putResponse.ok) return null; + const error = new Error(await buildStorageUploadError(putResponse, "\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0444\u0430\u0439\u043B\u0430 \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435")); + error.httpStatus = Number(putResponse.status || 0); + throw error; + }); + const completeData = await runUploadStepWithRetry("\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0444\u0430\u0439\u043B\u0430", async () => { + return apiJson( + "/api/public/uploads/complete", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key: initData.key, + file_name: file.name, + mime_type: mimeType, + size_bytes: file.size, + scope: "REQUEST_ATTACHMENT", + request_id: requestId, + message_id: (extra == null ? void 0 : extra.message_id) || null + }) + }, + "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0444\u0430\u0439\u043B\u0430" + ); }); - if (!putResponse.ok) throw new Error(await buildStorageUploadError(putResponse, "\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0444\u0430\u0439\u043B\u0430 \u0432 \u0445\u0440\u0430\u043D\u0438\u043B\u0438\u0449\u0435")); - const completeData = await apiJson( - "/api/public/uploads/complete", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - key: initData.key, - file_name: file.name, - mime_type: mimeType, - size_bytes: file.size, - scope: "REQUEST_ATTACHMENT", - request_id: requestId, - message_id: (extra == null ? void 0 : extra.message_id) || null - }) - }, - "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0444\u0430\u0439\u043B\u0430" - ); return completeData; - }, [apiJson, buildStorageUploadError, requestModal.requestId]); + }, [apiJson, buildStorageUploadError, requestModal.requestId, runUploadStepWithRetry]); const loadRequestWorkspace = useCallback( async (trackNumber, showLoading) => { const track = String(trackNumber || "").trim().toUpperCase(); diff --git a/app/web/client.jsx b/app/web/client.jsx index 8b8d2fb..40c2a11 100644 --- a/app/web/client.jsx +++ b/app/web/client.jsx @@ -414,6 +414,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad } function App() { + const UPLOAD_MAX_ATTEMPTS = 4; const [requestModal, setRequestModal] = useState(createRequestModalState()); const [requestsList, setRequestsList] = useState([]); const [activeTrack, setActiveTrack] = useState(""); @@ -451,7 +452,11 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad window.location.href = "/"; throw new Error("Нет доступа"); } - if (!response.ok) throw new Error(apiError(data, fallbackMessage || "Ошибка запроса")); + if (!response.ok) { + const error = new Error(apiError(data, fallbackMessage || "Ошибка запроса")); + error.httpStatus = Number(response.status || 0); + throw error; + } return data; }, []); @@ -472,50 +477,105 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad return parts.length ? base + " (" + parts.join("; ") + ")" : base; }, []); + const wait = useCallback(async (ms) => { + await new Promise((resolve) => window.setTimeout(resolve, Math.max(0, Number(ms) || 0))); + }, []); + + const nextUploadRetryDelayMs = useCallback((attempt) => { + const base = Math.min(1200 * Math.pow(2, Math.max(0, Number(attempt || 1) - 1)), 7000); + const jitter = Math.floor(Math.random() * 250); + return base + jitter; + }, []); + + const isRetryableUploadError = useCallback((error) => { + const status = Number(error?.httpStatus || error?.status || 0); + if ([408, 425, 429, 500, 502, 503, 504].includes(status)) return true; + if (status > 0) return false; + const message = String(error?.message || "").toLowerCase(); + if (!message) return true; + return ( + message.includes("networkerror") || + message.includes("failed to fetch") || + message.includes("load failed") || + message.includes("network request failed") || + message.includes("timeout") + ); + }, []); + + const runUploadStepWithRetry = useCallback( + async (label, action) => { + let lastError = null; + let attemptsUsed = 0; + for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt += 1) { + attemptsUsed = attempt; + try { + return await action(attempt); + } catch (error) { + lastError = error; + const canRetry = attempt < UPLOAD_MAX_ATTEMPTS && isRetryableUploadError(error); + if (!canRetry) break; + await wait(nextUploadRetryDelayMs(attempt)); + } + } + const reason = String(lastError?.message || "Ошибка сети"); + throw new Error(label + ": " + reason + " (попыток: " + attemptsUsed + ")"); + }, + [UPLOAD_MAX_ATTEMPTS, isRetryableUploadError, nextUploadRetryDelayMs, wait] + ); + const uploadPublicRequestAttachment = useCallback(async (file, extra = {}) => { const requestId = String(requestModal.requestId || "").trim(); if (!requestId) throw new Error("Не выбрана заявка"); const mimeType = String(file?.type || "application/octet-stream"); - const initData = await apiJson( - "/api/public/uploads/init", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - file_name: file.name, - mime_type: mimeType, - size_bytes: file.size, - scope: "REQUEST_ATTACHMENT", - request_id: requestId, - }), - }, - "Не удалось начать загрузку файла" - ); - const putResponse = await fetch(initData.presigned_url, { - method: "PUT", - headers: { "Content-Type": mimeType }, - body: file, + const initData = await runUploadStepWithRetry("Не удалось начать загрузку файла", async () => { + return apiJson( + "/api/public/uploads/init", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file_name: file.name, + mime_type: mimeType, + size_bytes: file.size, + scope: "REQUEST_ATTACHMENT", + request_id: requestId, + }), + }, + "Не удалось начать загрузку файла" + ); + }); + await runUploadStepWithRetry("Ошибка передачи файла в хранилище", async () => { + const putResponse = await fetch(initData.presigned_url, { + method: "PUT", + headers: { "Content-Type": mimeType }, + body: file, + }); + if (putResponse.ok) return null; + const error = new Error(await buildStorageUploadError(putResponse, "Ошибка передачи файла в хранилище")); + error.httpStatus = Number(putResponse.status || 0); + throw error; + }); + const completeData = await runUploadStepWithRetry("Не удалось завершить загрузку файла", async () => { + return apiJson( + "/api/public/uploads/complete", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key: initData.key, + file_name: file.name, + mime_type: mimeType, + size_bytes: file.size, + scope: "REQUEST_ATTACHMENT", + request_id: requestId, + message_id: extra?.message_id || null, + }), + }, + "Не удалось завершить загрузку файла" + ); }); - if (!putResponse.ok) throw new Error(await buildStorageUploadError(putResponse, "Ошибка передачи файла в хранилище")); - const completeData = await apiJson( - "/api/public/uploads/complete", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - key: initData.key, - file_name: file.name, - mime_type: mimeType, - size_bytes: file.size, - scope: "REQUEST_ATTACHMENT", - request_id: requestId, - message_id: extra?.message_id || null, - }), - }, - "Не удалось завершить загрузку файла" - ); return completeData; - }, [apiJson, buildStorageUploadError, requestModal.requestId]); + }, [apiJson, buildStorageUploadError, requestModal.requestId, runUploadStepWithRetry]); const loadRequestWorkspace = useCallback( async (trackNumber, showLoading) => { diff --git a/tests/test_featured_staff_public.py b/tests/test_featured_staff_public.py new file mode 100644 index 0000000..9003220 --- /dev/null +++ b/tests/test_featured_staff_public.py @@ -0,0 +1,152 @@ +import os +import unittest +from unittest.mock import patch + +from botocore.exceptions import ClientError +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, delete +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") +os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000") +os.environ.setdefault("S3_ACCESS_KEY", "test") +os.environ.setdefault("S3_SECRET_KEY", "test") +os.environ.setdefault("S3_BUCKET", "test") + +from app.db.session import get_db +from app.main import app +from app.models.admin_user import AdminUser +from app.models.landing_featured_staff import LandingFeaturedStaff +from app.models.topic import Topic + + +class _FakeBody: + def __init__(self, payload: bytes): + self.payload = payload + + def iter_chunks(self, chunk_size=65536): + for i in range(0, len(self.payload), chunk_size): + yield self.payload[i : i + chunk_size] + + +class _FakeS3Storage: + def __init__(self): + self.objects = {} + + def get_object(self, key: str) -> dict: + obj = self.objects.get(key) + if obj is None: + raise ClientError({"Error": {"Code": "404", "Message": "Not Found"}}, "GetObject") + return {"Body": _FakeBody(obj["content"]), "ContentType": obj["mime"], "ContentLength": obj["size"]} + + +class FeaturedStaffPublicTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False) + AdminUser.__table__.create(bind=cls.engine) + Topic.__table__.create(bind=cls.engine) + LandingFeaturedStaff.__table__.create(bind=cls.engine) + + @classmethod + def tearDownClass(cls): + LandingFeaturedStaff.__table__.drop(bind=cls.engine) + Topic.__table__.drop(bind=cls.engine) + AdminUser.__table__.drop(bind=cls.engine) + cls.engine.dispose() + + def setUp(self): + with self.SessionLocal() as db: + db.execute(delete(LandingFeaturedStaff)) + db.execute(delete(Topic)) + db.execute(delete(AdminUser)) + db.commit() + + def override_get_db(): + db = self.SessionLocal() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_get_db + self.client = TestClient(app) + + def tearDown(self): + self.client.close() + app.dependency_overrides.clear() + + def _seed_featured_lawyer(self, enabled: bool = True): + with self.SessionLocal() as db: + topic = Topic(code="consulting", name="consulting", enabled=True, sort_order=1) + user = AdminUser( + role="LAWYER", + name="Юрист", + email="lawyer-featured@example.com", + password_hash="hash", + is_active=True, + primary_topic_code="consulting", + ) + db.add_all([topic, user]) + db.flush() + avatar_key = f"avatars/{user.id}/avatar.webp" + user.avatar_url = "s3://" + avatar_key + slot = LandingFeaturedStaff( + admin_user_id=user.id, + caption="Я крут!", + sort_order=10, + pinned=True, + enabled=enabled, + ) + db.add(slot) + db.commit() + return str(user.id), avatar_key + + def test_list_featured_staff_returns_public_avatar_proxy_url(self): + user_id, _ = self._seed_featured_lawyer(enabled=True) + + response = self.client.get("/api/public/featured-staff?limit=24") + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload.get("total"), 1) + self.assertEqual(len(payload.get("items") or []), 1) + row = payload["items"][0] + self.assertEqual(row.get("admin_user_id"), user_id) + self.assertEqual(row.get("avatar_url"), "/api/public/featured-staff/avatar/" + user_id) + + def test_featured_staff_avatar_proxy_streams_s3_avatar(self): + user_id, avatar_key = self._seed_featured_lawyer(enabled=True) + fake_s3 = _FakeS3Storage() + fake_s3.objects[avatar_key] = { + "size": 7, + "mime": "image/webp", + "content": b"webpimg", + } + + with patch("app.api.public.featured_staff.get_s3_storage", return_value=fake_s3): + response = self.client.get("/api/public/featured-staff/avatar/" + user_id) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"webpimg") + self.assertIn("image/webp", response.headers.get("content-type", "")) + + def test_featured_staff_avatar_proxy_denies_non_featured_user(self): + user_id, avatar_key = self._seed_featured_lawyer(enabled=False) + fake_s3 = _FakeS3Storage() + fake_s3.objects[avatar_key] = { + "size": 7, + "mime": "image/webp", + "content": b"webpimg", + } + + with patch("app.api.public.featured_staff.get_s3_storage", return_value=fake_s3): + response = self.client.get("/api/public/featured-staff/avatar/" + user_id) + + self.assertEqual(response.status_code, 404) diff --git a/tests/test_uploads_s3.py b/tests/test_uploads_s3.py index d23e64a..571aa30 100644 --- a/tests/test_uploads_s3.py +++ b/tests/test_uploads_s3.py @@ -285,6 +285,63 @@ class UploadsS3Tests(unittest.TestCase): self.assertEqual(len(rows), 1) self.assertEqual(rows[0].s3_key, key) + def test_public_upload_complete_is_idempotent_for_same_key(self): + fake_s3 = _FakeS3Storage() + with self.SessionLocal() as db: + req = Request( + track_number="TRK-PUB-IDEMPOTENT", + client_name="Клиент", + client_phone="+79991112244", + topic_code="civil-law", + status_code="NEW", + extra_fields={}, + total_attachments_bytes=0, + ) + db.add(req) + db.commit() + request_id = str(req.id) + track = req.track_number + + public_token = create_jwt({"sub": track, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1)) + cookies = {settings.PUBLIC_COOKIE_NAME: public_token} + + with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3): + init_resp = self.client.post( + "/api/public/uploads/init", + cookies=cookies, + json={ + "file_name": "retry-safe.pdf", + "mime_type": "application/pdf", + "size_bytes": 4096, + "scope": "REQUEST_ATTACHMENT", + "request_id": request_id, + }, + ) + self.assertEqual(init_resp.status_code, 200) + key = init_resp.json()["key"] + payload = { + "key": key, + "file_name": "retry-safe.pdf", + "mime_type": "application/pdf", + "size_bytes": 4096, + "scope": "REQUEST_ATTACHMENT", + "request_id": request_id, + } + fake_s3.objects[key] = {"size": 4096, "mime": "application/pdf", "content": b"p" * 4096} + + first = self.client.post("/api/public/uploads/complete", cookies=cookies, json=payload) + second = self.client.post("/api/public/uploads/complete", cookies=cookies, json=payload) + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual(first.json().get("attachment_id"), second.json().get("attachment_id")) + + with self.SessionLocal() as db: + req = db.get(Request, UUID(request_id)) + self.assertIsNotNone(req) + self.assertEqual(req.total_attachments_bytes, 4096) + attachments = db.query(Attachment).filter(Attachment.request_id == UUID(request_id)).all() + self.assertEqual(len(attachments), 1) + def test_public_attachment_object_preview_returns_inline_response(self): fake_s3 = _FakeS3Storage() with self.SessionLocal() as db: @@ -428,6 +485,68 @@ class UploadsS3Tests(unittest.TestCase): self.assertTrue(req.client_has_unread_updates) self.assertEqual(req.client_unread_event_type, "ATTACHMENT") + def test_admin_upload_complete_is_idempotent_for_same_key(self): + fake_s3 = _FakeS3Storage() + with self.SessionLocal() as db: + admin = AdminUser( + role="ADMIN", + name="Админ Идемпотентность", + email="admin-idempotent@example.com", + password_hash="hash", + is_active=True, + ) + req = Request( + track_number="TRK-ADM-IDEMPOTENT", + client_name="Клиент", + client_phone="+79995556644", + topic_code="civil-law", + status_code="IN_PROGRESS", + extra_fields={}, + total_attachments_bytes=0, + ) + db.add_all([admin, req]) + db.commit() + admin_id = str(admin.id) + request_id = str(req.id) + + headers = self._admin_headers(sub=admin_id, role="ADMIN", email="admin-idempotent@example.com") + with patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3): + init_resp = self.client.post( + "/api/admin/uploads/init", + headers=headers, + json={ + "file_name": "retry-safe-admin.pdf", + "mime_type": "application/pdf", + "size_bytes": 2048, + "scope": "REQUEST_ATTACHMENT", + "request_id": request_id, + }, + ) + self.assertEqual(init_resp.status_code, 200) + key = init_resp.json()["key"] + payload = { + "key": key, + "file_name": "retry-safe-admin.pdf", + "mime_type": "application/pdf", + "size_bytes": 2048, + "scope": "REQUEST_ATTACHMENT", + "request_id": request_id, + } + fake_s3.objects[key] = {"size": 2048, "mime": "application/pdf", "content": b"x" * 2048} + + first = self.client.post("/api/admin/uploads/complete", headers=headers, json=payload) + second = self.client.post("/api/admin/uploads/complete", headers=headers, json=payload) + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual(first.json().get("attachment_id"), second.json().get("attachment_id")) + + with self.SessionLocal() as db: + req = db.get(Request, UUID(request_id)) + self.assertIsNotNone(req) + self.assertEqual(req.total_attachments_bytes, 2048) + attachments = db.query(Attachment).filter(Attachment.request_id == UUID(request_id)).all() + self.assertEqual(len(attachments), 1) + def test_admin_upload_rejects_attachment_for_immutable_message(self): fake_s3 = _FakeS3Storage() with self.SessionLocal() as db: