mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
fix user UI 9
This commit is contained in:
parent
f833c0a9aa
commit
46234f6d51
10 changed files with 777 additions and 202 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
157
app/web/admin.js
157
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 } = {}) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,11 +2537,48 @@
|
|||
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(
|
||||
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",
|
||||
|
|
@ -2551,13 +2593,20 @@
|
|||
},
|
||||
"\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) 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(
|
||||
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",
|
||||
|
|
@ -2574,8 +2623,9 @@
|
|||
},
|
||||
"\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();
|
||||
|
|
|
|||
|
|
@ -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,11 +477,58 @@ 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(
|
||||
const initData = await runUploadStepWithRetry("Не удалось начать загрузку файла", async () => {
|
||||
return apiJson(
|
||||
"/api/public/uploads/init",
|
||||
{
|
||||
method: "POST",
|
||||
|
|
@ -491,13 +543,20 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
|||
},
|
||||
"Не удалось начать загрузку файла"
|
||||
);
|
||||
});
|
||||
await runUploadStepWithRetry("Ошибка передачи файла в хранилище", async () => {
|
||||
const putResponse = await fetch(initData.presigned_url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": mimeType },
|
||||
body: file,
|
||||
});
|
||||
if (!putResponse.ok) throw new Error(await buildStorageUploadError(putResponse, "Ошибка передачи файла в хранилище"));
|
||||
const completeData = await apiJson(
|
||||
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",
|
||||
|
|
@ -514,8 +573,9 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
|||
},
|
||||
"Не удалось завершить загрузку файла"
|
||||
);
|
||||
});
|
||||
return completeData;
|
||||
}, [apiJson, buildStorageUploadError, requestModal.requestId]);
|
||||
}, [apiJson, buildStorageUploadError, requestModal.requestId, runUploadStepWithRetry]);
|
||||
|
||||
const loadRequestWorkspace = useCallback(
|
||||
async (trackNumber, showLoading) => {
|
||||
|
|
|
|||
152
tests/test_featured_staff_public.py
Normal file
152
tests/test_featured_staff_public.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue