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:
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
157
app/web/admin.js
157
app/web/admin.js
|
|
@ -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 } = {}) => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,11 +2537,48 @@
|
||||||
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 () => {
|
||||||
|
return apiJson(
|
||||||
"/api/public/uploads/init",
|
"/api/public/uploads/init",
|
||||||
{
|
{
|
||||||
method: "POST",
|
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"
|
"\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, {
|
const putResponse = await fetch(initData.presigned_url, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": mimeType },
|
headers: { "Content-Type": mimeType },
|
||||||
body: file
|
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"));
|
if (putResponse.ok) return null;
|
||||||
const completeData = await apiJson(
|
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",
|
"/api/public/uploads/complete",
|
||||||
{
|
{
|
||||||
method: "POST",
|
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"
|
"\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();
|
||||||
|
|
|
||||||
|
|
@ -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,11 +477,58 @@ 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 () => {
|
||||||
|
return apiJson(
|
||||||
"/api/public/uploads/init",
|
"/api/public/uploads/init",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -491,13 +543,20 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
},
|
},
|
||||||
"Не удалось начать загрузку файла"
|
"Не удалось начать загрузку файла"
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
await runUploadStepWithRetry("Ошибка передачи файла в хранилище", async () => {
|
||||||
const putResponse = await fetch(initData.presigned_url, {
|
const putResponse = await fetch(initData.presigned_url, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": mimeType },
|
headers: { "Content-Type": mimeType },
|
||||||
body: file,
|
body: file,
|
||||||
});
|
});
|
||||||
if (!putResponse.ok) throw new Error(await buildStorageUploadError(putResponse, "Ошибка передачи файла в хранилище"));
|
if (putResponse.ok) return null;
|
||||||
const completeData = await apiJson(
|
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",
|
"/api/public/uploads/complete",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -514,8 +573,9 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
},
|
},
|
||||||
"Не удалось завершить загрузку файла"
|
"Не удалось завершить загрузку файла"
|
||||||
);
|
);
|
||||||
|
});
|
||||||
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) => {
|
||||||
|
|
|
||||||
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(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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue