fix user UI 9

This commit is contained in:
TronoSfera 2026-03-03 17:50:17 +03:00
parent f833c0a9aa
commit 46234f6d51
10 changed files with 777 additions and 202 deletions

View file

@ -285,6 +285,31 @@ def upload_complete(
if request is None: if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена") raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/") _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) _ensure_case_capacity_or_400(request, actual_size)
message_uuid = None message_uuid = None

View file

@ -1,6 +1,10 @@
from __future__ import annotations 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 import and_
from sqlalchemy.orm import Session 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.admin_user import AdminUser
from app.models.landing_featured_staff import LandingFeaturedStaff from app.models.landing_featured_staff import LandingFeaturedStaff
from app.models.topic import Topic from app.models.topic import Topic
from app.services.s3_storage import get_s3_storage
router = APIRouter() router = APIRouter()
def _featured_avatar_proxy_path(admin_user_id: str) -> str:
return "/api/public/featured-staff/avatar/" + str(admin_user_id)
@router.get("") @router.get("")
def list_featured_staff( def list_featured_staff(
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
@ -46,6 +55,10 @@ def list_featured_staff(
role_code = str(user.role or "").upper() role_code = str(user.role or "").upper()
role_label = "Администратор" if role_code == "ADMIN" else "Юрист" role_label = "Администратор" if role_code == "ADMIN" else "Юрист"
primary_topic_code = str(user.primary_topic_code or "").strip() or None 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( result.append(
{ {
"id": str(slot.id), "id": str(slot.id),
@ -53,7 +66,7 @@ def list_featured_staff(
"name": user.name, "name": user.name,
"role": role_code, "role": role_code,
"role_label": role_label, "role_label": role_label,
"avatar_url": user.avatar_url, "avatar_url": avatar_url,
"caption": str(slot.caption or "").strip() or None, "caption": str(slot.caption or "").strip() or None,
"pinned": bool(slot.pinned), "pinned": bool(slot.pinned),
"sort_order": int(slot.sort_order or 0), "sort_order": int(slot.sort_order or 0),
@ -62,3 +75,52 @@ def list_featured_staff(
} }
) )
return {"items": result, "total": len(result)} 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)

View file

@ -185,6 +185,31 @@ def upload_complete(
raise HTTPException(status_code=404, detail="Заявка не найдена") raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_public_request_access_or_403(request, session) _ensure_public_request_access_or_403(request, session)
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/") _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() storage = get_s3_storage()
try: try:

View file

@ -5764,7 +5764,9 @@
} }
if (!response.ok) { if (!response.ok) {
const message = payload && (payload.detail || payload.error || payload.raw) || "HTTP " + response.status; 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; return payload;
}, },
@ -6025,6 +6027,7 @@
bank_account: "40702810501860000582", bank_account: "40702810501860000582",
bank_corr_account: "30101810200000000593" bank_corr_account: "30101810200000000593"
}); });
var UPLOAD_MAX_ATTEMPTS = 4;
async function buildStorageUploadError(response, fallbackMessage) { 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 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); const status = Number((response == null ? void 0 : response.status) || 0);
@ -6041,6 +6044,22 @@
if (details) parts.push(details); if (details) parts.push(details);
return parts.length ? base + " (" + parts.join("; ") + ")" : base; 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) { function useRequestWorkspace(options) {
const { useCallback, useRef, useState } = React; const { useCallback, useRef, useState } = React;
const opts = options || {}; const opts = options || {};
@ -6086,6 +6105,70 @@
const clearRequestModalFiles = useCallback(() => { const clearRequestModalFiles = useCallback(() => {
setRequestModal((prev) => ({ ...prev, selectedFiles: [] })); 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( const loadRequestModalData = useCallback(
async (requestId, loadOptions) => { async (requestId, loadOptions) => {
if (!api || !requestId) return; if (!api || !requestId) return;
@ -6261,35 +6344,7 @@
messageId = String((message == null ? void 0 : message.id) || "").trim() || null; messageId = String((message == null ? void 0 : message.id) || "").trim() || null;
} }
for (const file of files) { for (const file of files) {
const mimeType = String(file.type || "application/octet-stream"); await uploadRequestAttachmentWithRetry({ requestId, file, messageId });
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
}
});
} }
setRequestModal((prev) => ({ ...prev, messageDraft: "", selectedFiles: [], fileUploading: false })); 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"; 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"); 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( const loadRequestDataTemplates = useCallback(
async (documentName) => { async (documentName) => {
@ -6420,41 +6483,13 @@
messageId = String((message == null ? void 0 : message.id) || "").trim() || null; messageId = String((message == null ? void 0 : message.id) || "").trim() || null;
} }
for (const file of attachedFiles) { for (const file of attachedFiles) {
const mimeType = String(file.type || "application/octet-stream"); await uploadRequestAttachmentWithRetry({ requestId: targetRequestId, file, messageId });
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
}
});
} }
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"); 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 }); await loadRequestModalData(targetRequestId, { showLoading: false });
return result; return result;
}, },
[api, loadRequestModalData, requestModal.availableStatuses, requestModal.requestId, setStatus] [api, loadRequestModalData, requestModal.availableStatuses, requestModal.requestId, setStatus, uploadRequestAttachmentWithRetry]
); );
const issueRequestInvoice = useCallback( const issueRequestInvoice = useCallback(
async ({ requestId, amount, serviceDescription, payerDisplayName } = {}) => { async ({ requestId, amount, serviceDescription, payerDisplayName } = {}) => {

View file

@ -30,7 +30,9 @@ export function useAdminApi(token) {
if (!response.ok) { if (!response.ok) {
const message = (payload && (payload.detail || payload.error || payload.raw)) || "HTTP " + response.status; 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; return payload;

View file

@ -11,6 +11,7 @@ const DEFAULT_INVOICE_REQUISITES = Object.freeze({
bank_account: "40702810501860000582", bank_account: "40702810501860000582",
bank_corr_account: "30101810200000000593", bank_corr_account: "30101810200000000593",
}); });
const UPLOAD_MAX_ATTEMPTS = 4;
async function buildStorageUploadError(response, fallbackMessage) { async function buildStorageUploadError(response, fallbackMessage) {
const base = String(fallbackMessage || "Не удалось загрузить файл в хранилище"); const base = String(fallbackMessage || "Не удалось загрузить файл в хранилище");
@ -29,6 +30,31 @@ async function buildStorageUploadError(response, fallbackMessage) {
return parts.length ? base + " (" + parts.join("; ") + ")" : base; 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) { export function useRequestWorkspace(options) {
const { useCallback, useRef, useState } = React; const { useCallback, useRef, useState } = React;
const opts = options || {}; const opts = options || {};
@ -85,6 +111,73 @@ export function useRequestWorkspace(options) {
setRequestModal((prev) => ({ ...prev, selectedFiles: [] })); 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( const loadRequestModalData = useCallback(
async (requestId, loadOptions) => { async (requestId, loadOptions) => {
if (!api || !requestId) return; if (!api || !requestId) return;
@ -264,35 +357,7 @@ export function useRequestWorkspace(options) {
} }
for (const file of files) { for (const file of files) {
const mimeType = String(file.type || "application/octet-stream"); await uploadRequestAttachmentWithRetry({ requestId, file, messageId });
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,
},
});
} }
setRequestModal((prev) => ({ ...prev, messageDraft: "", selectedFiles: [], fileUploading: false })); setRequestModal((prev) => ({ ...prev, messageDraft: "", selectedFiles: [], fileUploading: false }));
@ -304,7 +369,15 @@ export function useRequestWorkspace(options) {
if (typeof setStatus === "function") setStatus("requestModal", "Ошибка отправки: " + error.message, "error"); 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( const loadRequestDataTemplates = useCallback(
@ -439,42 +512,14 @@ export function useRequestWorkspace(options) {
messageId = String(message?.id || "").trim() || null; messageId = String(message?.id || "").trim() || null;
} }
for (const file of attachedFiles) { for (const file of attachedFiles) {
const mimeType = String(file.type || "application/octet-stream"); await uploadRequestAttachmentWithRetry({ requestId: targetRequestId, file, messageId });
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,
},
});
} }
if (typeof setStatus === "function") setStatus("requestModal", "Статус заявки обновлен", "ok"); if (typeof setStatus === "function") setStatus("requestModal", "Статус заявки обновлен", "ok");
await loadRequestModalData(targetRequestId, { showLoading: false }); await loadRequestModalData(targetRequestId, { showLoading: false });
return result; return result;
}, },
[api, loadRequestModalData, requestModal.availableStatuses, requestModal.requestId, setStatus] [api, loadRequestModalData, requestModal.availableStatuses, requestModal.requestId, setStatus, uploadRequestAttachmentWithRetry]
); );
const issueRequestInvoice = useCallback( const issueRequestInvoice = useCallback(

View file

@ -2480,6 +2480,7 @@
} }
function App() { function App() {
var _a, _b; var _a, _b;
const UPLOAD_MAX_ATTEMPTS = 4;
const [requestModal, setRequestModal] = useState(createRequestModalState()); const [requestModal, setRequestModal] = useState(createRequestModalState());
const [requestsList, setRequestsList] = useState([]); const [requestsList, setRequestsList] = useState([]);
const [activeTrack, setActiveTrack] = useState(""); const [activeTrack, setActiveTrack] = useState("");
@ -2513,7 +2514,11 @@
window.location.href = "/"; window.location.href = "/";
throw new Error("\u041D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0430"); 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; return data;
}, []); }, []);
const buildStorageUploadError = useCallback(async (response, fallbackMessage) => { const buildStorageUploadError = useCallback(async (response, fallbackMessage) => {
@ -2532,50 +2537,95 @@
if (details) parts.push(details); if (details) parts.push(details);
return parts.length ? base + " (" + parts.join("; ") + ")" : base; 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 uploadPublicRequestAttachment = useCallback(async (file, extra = {}) => {
const requestId = String(requestModal.requestId || "").trim(); 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"); 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 mimeType = String((file == null ? void 0 : file.type) || "application/octet-stream");
const initData = await apiJson( 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 () => {
"/api/public/uploads/init", return apiJson(
{ "/api/public/uploads/init",
method: "POST", {
headers: { "Content-Type": "application/json" }, method: "POST",
body: JSON.stringify({ headers: { "Content-Type": "application/json" },
file_name: file.name, body: JSON.stringify({
mime_type: mimeType, file_name: file.name,
size_bytes: file.size, mime_type: mimeType,
scope: "REQUEST_ATTACHMENT", size_bytes: file.size,
request_id: requestId 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" },
); "\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 }, 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 () => {
body: file 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; return completeData;
}, [apiJson, buildStorageUploadError, requestModal.requestId]); }, [apiJson, buildStorageUploadError, requestModal.requestId, runUploadStepWithRetry]);
const loadRequestWorkspace = useCallback( const loadRequestWorkspace = useCallback(
async (trackNumber, showLoading) => { async (trackNumber, showLoading) => {
const track = String(trackNumber || "").trim().toUpperCase(); const track = String(trackNumber || "").trim().toUpperCase();

View file

@ -414,6 +414,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
} }
function App() { function App() {
const UPLOAD_MAX_ATTEMPTS = 4;
const [requestModal, setRequestModal] = useState(createRequestModalState()); const [requestModal, setRequestModal] = useState(createRequestModalState());
const [requestsList, setRequestsList] = useState([]); const [requestsList, setRequestsList] = useState([]);
const [activeTrack, setActiveTrack] = useState(""); const [activeTrack, setActiveTrack] = useState("");
@ -451,7 +452,11 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
window.location.href = "/"; window.location.href = "/";
throw new Error("Нет доступа"); 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; return data;
}, []); }, []);
@ -472,50 +477,105 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
return parts.length ? base + " (" + parts.join("; ") + ")" : base; 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 uploadPublicRequestAttachment = useCallback(async (file, extra = {}) => {
const requestId = String(requestModal.requestId || "").trim(); const requestId = String(requestModal.requestId || "").trim();
if (!requestId) throw new Error("Не выбрана заявка"); if (!requestId) throw new Error("Не выбрана заявка");
const mimeType = String(file?.type || "application/octet-stream"); const mimeType = String(file?.type || "application/octet-stream");
const initData = await apiJson( const initData = await runUploadStepWithRetry("Не удалось начать загрузку файла", async () => {
"/api/public/uploads/init", return apiJson(
{ "/api/public/uploads/init",
method: "POST", {
headers: { "Content-Type": "application/json" }, method: "POST",
body: JSON.stringify({ headers: { "Content-Type": "application/json" },
file_name: file.name, body: JSON.stringify({
mime_type: mimeType, file_name: file.name,
size_bytes: file.size, mime_type: mimeType,
scope: "REQUEST_ATTACHMENT", size_bytes: file.size,
request_id: requestId, scope: "REQUEST_ATTACHMENT",
}), request_id: requestId,
}, }),
"Не удалось начать загрузку файла" },
); "Не удалось начать загрузку файла"
const putResponse = await fetch(initData.presigned_url, { );
method: "PUT", });
headers: { "Content-Type": mimeType }, await runUploadStepWithRetry("Ошибка передачи файла в хранилище", async () => {
body: file, 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; return completeData;
}, [apiJson, buildStorageUploadError, requestModal.requestId]); }, [apiJson, buildStorageUploadError, requestModal.requestId, runUploadStepWithRetry]);
const loadRequestWorkspace = useCallback( const loadRequestWorkspace = useCallback(
async (trackNumber, showLoading) => { async (trackNumber, showLoading) => {

View file

@ -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)

View file

@ -285,6 +285,63 @@ class UploadsS3Tests(unittest.TestCase):
self.assertEqual(len(rows), 1) self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].s3_key, key) 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): def test_public_attachment_object_preview_returns_inline_response(self):
fake_s3 = _FakeS3Storage() fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db: with self.SessionLocal() as db:
@ -428,6 +485,68 @@ class UploadsS3Tests(unittest.TestCase):
self.assertTrue(req.client_has_unread_updates) self.assertTrue(req.client_has_unread_updates)
self.assertEqual(req.client_unread_event_type, "ATTACHMENT") 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): def test_admin_upload_rejects_attachment_for_immutable_message(self):
fake_s3 = _FakeS3Storage() fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db: with self.SessionLocal() as db: