fix upload files

This commit is contained in:
TronoSfera 2026-03-02 20:54:09 +03:00
parent e2dbf530bd
commit 94522c0747
13 changed files with 471 additions and 22 deletions

View file

@ -833,6 +833,9 @@ def upsert_data_request_batch(
for row in existing_message_rows: for row in existing_message_rows:
if row.key not in touched_keys: if row.key not in touched_keys:
db.delete(row) db.delete(row)
if existing_message is not None:
existing_message.updated_at = datetime.now(timezone.utc)
db.add(existing_message)
mark_unread_for_client(req, EVENT_REQUEST_DATA) mark_unread_for_client(req, EVENT_REQUEST_DATA)
req.responsible = responsible req.responsible = responsible
db.add(req) db.add(req)

View file

@ -5,6 +5,7 @@ import uuid
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import func
from sqlalchemy.inspection import inspect as sa_inspect from sqlalchemy.inspection import inspect as sa_inspect
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -609,11 +610,26 @@ def _make_unique_value(db: Session, model: type, field_name: str, base_value: st
idx += 1 idx += 1
def _next_sort_order_value(db: Session, model: type) -> int:
if "sort_order" not in _columns_map(model):
return 1
column = getattr(model, "sort_order", None)
if column is None:
return 1
current_max = db.query(func.max(column)).scalar()
try:
return int(current_max or 0) + 1
except (TypeError, ValueError):
return 1
def _apply_auto_fields_for_create(db: Session, model: type, table_name: str, payload: dict[str, Any]) -> dict[str, Any]: def _apply_auto_fields_for_create(db: Session, model: type, table_name: str, payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload) data = dict(payload)
if table_name == "topics" and not str(data.get("code") or "").strip(): if table_name == "topics" and not str(data.get("code") or "").strip():
base = _slugify(str(data.get("name") or ""), "topic") base = _slugify(str(data.get("name") or ""), "topic")
data["code"] = _make_unique_value(db, model, "code", base) data["code"] = _make_unique_value(db, model, "code", base)
if table_name == "topics":
data["sort_order"] = _next_sort_order_value(db, model)
if table_name == "statuses" and not str(data.get("code") or "").strip(): if table_name == "statuses" and not str(data.get("code") or "").strip():
base = _slugify(str(data.get("name") or ""), "status") base = _slugify(str(data.get("name") or ""), "status")
data["code"] = _make_unique_value(db, model, "code", base) data["code"] = _make_unique_value(db, model, "code", base)

View file

@ -391,6 +391,8 @@ def save_data_request_values(
updated += 1 updated += 1
if updated: if updated:
message.updated_at = datetime.now(timezone.utc)
db.add(message)
mark_unread_for_lawyer(req, EVENT_REQUEST_DATA) mark_unread_for_lawyer(req, EVENT_REQUEST_DATA)
req.responsible = "Клиент" req.responsible = "Клиент"
notify_request_event( notify_request_event(

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import uuid
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
@ -14,6 +15,7 @@ from app.services.notifications import EVENT_MESSAGE as NOTIFICATION_EVENT_MESSA
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_client, mark_unread_for_lawyer from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_client, mark_unread_for_lawyer
MAX_CHAT_MESSAGE_LEN = 12_000 MAX_CHAT_MESSAGE_LEN = 12_000
CHAT_PARTICIPANT_ADMIN_IDS_KEY = "chat_participant_admin_ids"
def _normalize_message_body(body: str | None) -> str: def _normalize_message_body(body: str | None) -> str:
@ -53,6 +55,38 @@ def _truncate_request_data_label(label: str, limit: int = 18) -> str:
return text[: max(3, limit - 3)].rstrip() + "..." return text[: max(3, limit - 3)].rstrip() + "..."
def _normalize_admin_uuid(value: str | None) -> str | None:
raw = str(value or "").strip()
if not raw:
return None
try:
return str(uuid.UUID(raw))
except (TypeError, ValueError):
return None
def _register_chat_participant(request: Request, admin_user_id: str | None) -> None:
normalized = _normalize_admin_uuid(admin_user_id)
if not normalized:
return
current = request.extra_fields if isinstance(request.extra_fields, dict) else {}
extra = dict(current or {})
raw_ids = extra.get(CHAT_PARTICIPANT_ADMIN_IDS_KEY)
known_ids: set[str] = set()
if isinstance(raw_ids, list):
for value in raw_ids:
item = _normalize_admin_uuid(value)
if item:
known_ids.add(item)
elif isinstance(raw_ids, str):
item = _normalize_admin_uuid(raw_ids)
if item:
known_ids.add(item)
known_ids.add(normalized)
extra[CHAT_PARTICIPANT_ADMIN_IDS_KEY] = sorted(known_ids)
request.extra_fields = extra
def serialize_messages_for_request(db: Session, request_id: Any, rows: list[Message]) -> list[dict[str, Any]]: def serialize_messages_for_request(db: Session, request_id: Any, rows: list[Message]) -> list[dict[str, Any]]:
message_ids = [] message_ids = []
for row in rows: for row in rows:
@ -212,6 +246,7 @@ def create_admin_or_lawyer_message(
body=message_body, body=message_body,
responsible=responsible, responsible=responsible,
) )
_register_chat_participant(request, actor_admin_user_id)
normalized_event = str(event_type or EVENT_MESSAGE).strip().upper() or EVENT_MESSAGE normalized_event = str(event_type or EVENT_MESSAGE).strip().upper() or EVENT_MESSAGE
mark_unread_for_client(request, normalized_event) mark_unread_for_client(request, normalized_event)
request.responsible = responsible request.responsible = responsible

View file

@ -33,6 +33,7 @@ _EVENT_LABELS = {
EVENT_ASSIGNMENT: "Заявка назначена", EVENT_ASSIGNMENT: "Заявка назначена",
EVENT_REASSIGNMENT: "Заявка переназначена", EVENT_REASSIGNMENT: "Заявка переназначена",
} }
CHAT_PARTICIPANT_ADMIN_IDS_KEY = "chat_participant_admin_ids"
def _as_utc_now() -> datetime: def _as_utc_now() -> datetime:
@ -93,6 +94,19 @@ def _active_admin_ids(db: Session, *, exclude_admin_user_id: uuid.UUID | None =
return out return out
def _chat_participant_admin_ids(request: Request) -> set[uuid.UUID]:
if not isinstance(request.extra_fields, dict):
return set()
raw = request.extra_fields.get(CHAT_PARTICIPANT_ADMIN_IDS_KEY)
values = raw if isinstance(raw, list) else [raw]
out: set[uuid.UUID] = set()
for value in values:
parsed = _as_uuid_or_none(value)
if parsed is not None:
out.add(parsed)
return out
def _create_notification( def _create_notification(
db: Session, db: Session,
*, *,
@ -237,10 +251,55 @@ def notify_request_event(
if row is not None: if row is not None:
internal_created += 1 internal_created += 1
def _notify_chat_participant_lawyers() -> None:
nonlocal internal_created
participant_ids = _chat_participant_admin_ids(request)
if not participant_ids:
return
assigned_lawyer_uuid = _as_uuid_or_none(request.assigned_lawyer_id)
target_ids = [item for item in participant_ids if actor_uuid is None or item != actor_uuid]
if not target_ids:
return
try:
rows = (
db.query(AdminUser.id, AdminUser.role, AdminUser.is_active)
.filter(AdminUser.id.in_(target_ids))
.all()
)
except SQLAlchemyError:
return
for admin_id, role, is_active in rows:
if not admin_id or not bool(is_active):
continue
role_code = str(role or "").strip().upper()
if role_code not in {"LAWYER", "CURATOR"}:
continue
if assigned_lawyer_uuid is not None and admin_id != assigned_lawyer_uuid:
continue
if assigned_lawyer_uuid is not None and admin_id == assigned_lawyer_uuid:
# Assigned lawyer already gets notification via _notify_lawyer_if_any.
continue
dedupe_key = _dedupe_key_for(f"participant:{admin_id}")
row = _create_notification(
db,
request=request,
recipient_type=RECIPIENT_ADMIN_USER,
recipient_admin_user_id=admin_id,
event_type=event,
title=title,
body=body,
payload=payload,
responsible=responsible,
dedupe_key=dedupe_key,
)
if row is not None:
internal_created += 1
if event in {EVENT_MESSAGE, EVENT_ATTACHMENT, EVENT_REQUEST_DATA}: if event in {EVENT_MESSAGE, EVENT_ATTACHMENT, EVENT_REQUEST_DATA}:
if actor == "CLIENT": if actor == "CLIENT":
_notify_lawyer_if_any() _notify_lawyer_if_any()
_notify_admins() _notify_admins()
_notify_chat_participant_lawyers()
else: else:
_notify_client() _notify_client()
elif event == EVENT_STATUS: elif event == EVENT_STATUS:

View file

@ -46,6 +46,11 @@ class S3Storage:
self.client.head_bucket(Bucket=self.bucket) self.client.head_bucket(Bucket=self.bucket)
except ClientError as exc: except ClientError as exc:
code = str(exc.response.get("Error", {}).get("Code", "")) code = str(exc.response.get("Error", {}).get("Code", ""))
# In production setups credentials may be scoped to object operations only.
# If bucket-level HeadBucket is forbidden, continue and let object-level calls decide.
if code in {"403", "AccessDenied", "Forbidden"}:
self._bucket_checked = True
return
if code not in {"404", "NoSuchBucket", "NotFound"}: if code not in {"404", "NoSuchBucket", "NotFound"}:
raise raise
kwargs: dict = {"Bucket": self.bucket} kwargs: dict = {"Bucket": self.bucket}

View file

@ -1303,6 +1303,16 @@
background: transparent; background: transparent;
} }
.config-floating-actions {
position: fixed;
right: 1.15rem;
top: 164px;
z-index: 35;
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.config-panel-flat .config-content .table-wrap table { .config-panel-flat .config-content .table-wrap table {
min-width: 640px; min-width: 640px;
} }
@ -3012,6 +3022,12 @@
width: 100%; width: 100%;
margin-left: 0; margin-left: 0;
} }
.config-floating-actions {
position: static;
justify-content: flex-end;
width: 100%;
margin-bottom: 0.5rem;
}
.topbar { .topbar {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;

View file

@ -107,6 +107,28 @@ export function ConfigSection(props) {
<div className="config-layout"> <div className="config-layout">
<div className="config-panel config-panel-flat"> <div className="config-panel config-panel-flat">
<div className="config-content"> <div className="config-content">
<div className="config-floating-actions">
<button
className="btn secondary table-control-btn"
type="button"
onClick={() => openCreateRecordModal(configActiveKey)}
disabled={!canCreateRecord}
title="Добавить"
aria-label="Добавить"
>
<AddIcon />
</button>
<button
className="btn secondary table-control-btn"
type="button"
onClick={() => openFilterModal(configActiveKey)}
disabled={!configActiveKey}
title="Фильтр"
aria-label="Фильтр"
>
<FilterIcon />
</button>
</div>
<FilterToolbar <FilterToolbar
filters={activeConfigTableState.filters} filters={activeConfigTableState.filters}
onOpen={() => openFilterModal(configActiveKey)} onOpen={() => openFilterModal(configActiveKey)}
@ -616,26 +638,6 @@ export function ConfigSection(props) {
> >
<RefreshIcon /> <RefreshIcon />
</button> </button>
<button
className="btn secondary table-control-btn"
type="button"
onClick={() => openCreateRecordModal(configActiveKey)}
disabled={!canCreateRecord}
title="Добавить"
aria-label="Добавить"
>
<AddIcon />
</button>
<button
className="btn secondary table-control-btn"
type="button"
onClick={() => openFilterModal(configActiveKey)}
disabled={!configActiveKey}
title="Фильтр"
aria-label="Фильтр"
>
<FilterIcon />
</button>
<button <button
className="btn secondary table-control-btn" className="btn secondary table-control-btn"
type="button" type="button"

View file

@ -331,6 +331,24 @@ class AdminAssignmentAndUsersTests(AdminUniversalCrudBase):
self.assertRegex(body2["code"], r"^[a-z0-9-]+$") self.assertRegex(body2["code"], r"^[a-z0-9-]+$")
self.assertNotEqual(body1["code"], body2["code"]) self.assertNotEqual(body1["code"], body2["code"])
def test_topic_sort_order_is_assigned_as_next_max_on_create(self):
headers = self._auth_headers("ADMIN")
first = self.client.post(
"/api/admin/crud/topics",
headers=headers,
json={"name": "Первая тема", "sort_order": 999},
)
self.assertEqual(first.status_code, 201)
self.assertEqual(int(first.json().get("sort_order") or 0), 1)
second = self.client.post(
"/api/admin/crud/topics",
headers=headers,
json={"name": "Вторая тема"},
)
self.assertEqual(second.status_code, 201)
self.assertEqual(int(second.json().get("sort_order") or 0), 2)
def test_admin_can_manage_users_with_password_hashing(self): def test_admin_can_manage_users_with_password_hashing(self):
headers = self._auth_headers("ADMIN", email="root@example.com") headers = self._auth_headers("ADMIN", email="root@example.com")
topic_create = self.client.post( topic_create = self.client.post(
@ -409,4 +427,3 @@ class AdminAssignmentAndUsersTests(AdminUniversalCrudBase):
deleted = self.client.delete(f"/api/admin/crud/admin_users/{user_id}", headers=headers) deleted = self.client.delete(f"/api/admin/crud/admin_users/{user_id}", headers=headers)
self.assertEqual(deleted.status_code, 200) self.assertEqual(deleted.status_code, 200)

View file

@ -538,3 +538,89 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
self.assertEqual(own_live_with_typing.status_code, 200) self.assertEqual(own_live_with_typing.status_code, 200)
typing_rows = own_live_with_typing.json().get("typing") or [] typing_rows = own_live_with_typing.json().get("typing") or []
self.assertTrue(any(str(item.get("actor_role")) == "ADMIN" for item in typing_rows)) self.assertTrue(any(str(item.get("actor_role")) == "ADMIN" for item in typing_rows))
def test_admin_live_detects_client_filled_request_data_updates(self):
with self.SessionLocal() as db:
now = datetime.now(timezone.utc)
lawyer_self = AdminUser(
role="LAWYER",
name="Юрист Data Live",
email="lawyer.data.live@example.com",
password_hash="hash",
is_active=True,
)
db.add(lawyer_self)
db.flush()
self_id = str(lawyer_self.id)
own = Request(
track_number="TRK-CHAT-LIVE-DATA",
client_name="Клиент Data Live",
client_phone="+79995550111",
status_code="IN_PROGRESS",
description="own data live",
extra_fields={},
assigned_lawyer_id=self_id,
)
db.add(own)
db.flush()
msg = Message(
request_id=own.id,
author_type="LAWYER",
author_name="Юрист",
body="Запрос",
created_at=now - timedelta(minutes=2),
updated_at=now - timedelta(minutes=2),
)
db.add(msg)
db.flush()
req_row = RequestDataRequirement(
request_id=own.id,
request_message_id=msg.id,
key="inn",
label="ИНН",
field_type="text",
required=True,
sort_order=0,
)
db.add(req_row)
db.commit()
own_id = str(own.id)
message_id = str(msg.id)
req_row_id = str(req_row.id)
lawyer_headers = self._auth_headers("LAWYER", email="lawyer.data.live@example.com", sub=self_id)
own_live = self.chat_client.get(f"/api/admin/chat/requests/{own_id}/live", headers=lawyer_headers)
self.assertEqual(own_live.status_code, 200)
own_cursor = str(own_live.json().get("cursor") or "")
self.assertTrue(bool(own_cursor))
own_live_no_delta = self.chat_client.get(
f"/api/admin/chat/requests/{own_id}/live",
headers=lawyer_headers,
params={"cursor": own_cursor},
)
self.assertEqual(own_live_no_delta.status_code, 200)
self.assertFalse(bool(own_live_no_delta.json().get("has_updates")))
public_token = create_jwt(
{"sub": "TRK-CHAT-LIVE-DATA", "purpose": "VIEW_REQUEST"},
settings.PUBLIC_JWT_SECRET,
timedelta(minutes=30),
)
public_cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
save_values = self.chat_client.post(
f"/api/public/chat/requests/TRK-CHAT-LIVE-DATA/data-requests/{message_id}",
cookies=public_cookies,
json={"items": [{"id": req_row_id, "value_text": "7701234567"}]},
)
self.assertEqual(save_values.status_code, 200)
self.assertEqual(int(save_values.json().get("updated") or 0), 1)
own_live_after_fill = self.chat_client.get(
f"/api/admin/chat/requests/{own_id}/live",
headers=lawyer_headers,
params={"cursor": own_cursor},
)
self.assertEqual(own_live_after_fill.status_code, 200)
self.assertTrue(bool(own_live_after_fill.json().get("has_updates")))

View file

@ -29,6 +29,7 @@ from app.models.request import Request
from app.models.status import Status from app.models.status import Status
from app.models.status_history import StatusHistory from app.models.status_history import StatusHistory
from app.models.topic_status_transition import TopicStatusTransition from app.models.topic_status_transition import TopicStatusTransition
from app.services.chat_secure_service import create_admin_or_lawyer_message
from app.services.notifications import EVENT_REQUEST_DATA, notify_request_event from app.services.notifications import EVENT_REQUEST_DATA, notify_request_event
from app.workers.tasks import sla as sla_task from app.workers.tasks import sla as sla_task
@ -392,6 +393,115 @@ class NotificationFlowTests(unittest.TestCase):
self.assertTrue(any(str(row.recipient_admin_user_id or "") == str(admin.id) for row in rows)) self.assertTrue(any(str(row.recipient_admin_user_id or "") == str(admin.id) for row in rows))
self.assertTrue(any(str(row.recipient_admin_user_id or "") == str(lawyer.id) for row in rows)) self.assertTrue(any(str(row.recipient_admin_user_id or "") == str(lawyer.id) for row in rows))
def test_client_event_notifies_participant_lawyer_when_request_unassigned(self):
with self.SessionLocal() as db:
lawyer = AdminUser(
role="LAWYER",
name="Юрист Участник",
email="lawyer.participant@example.com",
password_hash="hash",
is_active=True,
)
db.add(lawyer)
db.flush()
req = Request(
track_number="TRK-NOTIF-PARTICIPANT",
client_name="Клиент",
client_phone="+79990000033",
topic_code="civil",
status_code="IN_PROGRESS",
description="participant notification",
extra_fields={},
assigned_lawyer_id=None,
)
db.add(req)
db.commit()
create_admin_or_lawyer_message(
db,
request=req,
body="Сообщение юриста",
actor_role="LAWYER",
actor_name="Юрист Участник",
actor_admin_user_id=str(lawyer.id),
event_type="MESSAGE",
)
result = notify_request_event(
db,
request=req,
event_type=EVENT_REQUEST_DATA,
actor_role="CLIENT",
body="Клиент обновил данные",
responsible="Клиент",
send_telegram=False,
)
db.commit()
self.assertEqual(int(result.get("internal_created") or 0), 1)
rows = db.query(Notification).filter(Notification.event_type == "REQUEST_DATA").all()
self.assertEqual(len(rows), 1)
self.assertEqual(str(rows[0].recipient_admin_user_id), str(lawyer.id))
def test_client_event_stops_notifying_old_lawyer_after_reassign(self):
with self.SessionLocal() as db:
old_lawyer = AdminUser(
role="LAWYER",
name="Юрист Старый Участник",
email="lawyer.old.participant@example.com",
password_hash="hash",
is_active=True,
)
new_lawyer = AdminUser(
role="LAWYER",
name="Юрист Новый Назначенный",
email="lawyer.new.assigned@example.com",
password_hash="hash",
is_active=True,
)
db.add_all([old_lawyer, new_lawyer])
db.flush()
req = Request(
track_number="TRK-NOTIF-PARTICIPANT-REASSIGN",
client_name="Клиент",
client_phone="+79990000034",
topic_code="civil",
status_code="IN_PROGRESS",
description="participant reassignment notification",
extra_fields={},
assigned_lawyer_id=None,
)
db.add(req)
db.commit()
create_admin_or_lawyer_message(
db,
request=req,
body="Сообщение старого юриста",
actor_role="LAWYER",
actor_name="Юрист Старый Участник",
actor_admin_user_id=str(old_lawyer.id),
event_type="MESSAGE",
)
req.assigned_lawyer_id = str(new_lawyer.id)
db.add(req)
db.commit()
result = notify_request_event(
db,
request=req,
event_type=EVENT_REQUEST_DATA,
actor_role="CLIENT",
body="Клиент обновил данные",
responsible="Клиент",
send_telegram=False,
)
db.commit()
self.assertEqual(int(result.get("internal_created") or 0), 1)
rows = db.query(Notification).filter(Notification.event_type == "REQUEST_DATA").all()
self.assertEqual(len(rows), 1)
self.assertEqual(str(rows[0].recipient_admin_user_id), str(new_lawyer.id))
class NotificationSlaTests(unittest.TestCase): class NotificationSlaTests(unittest.TestCase):
@classmethod @classmethod

View file

@ -1,6 +1,6 @@
import os import os
import unittest import unittest
from datetime import timedelta from datetime import datetime, timedelta, timezone
from uuid import UUID from uuid import UUID
from unittest.mock import patch from unittest.mock import patch
@ -377,6 +377,74 @@ class PublicCabinetTests(unittest.TestCase):
self.assertEqual(typing_on.status_code, 200) self.assertEqual(typing_on.status_code, 200)
self.assertTrue(bool(typing_on.json().get("typing"))) self.assertTrue(bool(typing_on.json().get("typing")))
def test_public_live_detects_filled_request_data_updates(self):
with self.SessionLocal() as db:
now = datetime.now(timezone.utc)
req = Request(
track_number="TRK-LIVE-DATA-001",
client_name="Клиент Live Data",
client_phone="+79997771235",
topic_code="consulting",
status_code="IN_PROGRESS",
description="Проверка live по допданным",
extra_fields={},
)
db.add(req)
db.flush()
msg = Message(
request_id=req.id,
author_type="LAWYER",
author_name="Юрист",
body="Запрос",
created_at=now - timedelta(minutes=2),
updated_at=now - timedelta(minutes=2),
)
db.add(msg)
db.flush()
row = RequestDataRequirement(
request_id=req.id,
request_message_id=msg.id,
key="passport_series",
label="Серия паспорта",
field_type="text",
required=True,
sort_order=0,
)
db.add(row)
db.commit()
message_id = str(msg.id)
row_id = str(row.id)
cookies = self._public_cookies("TRK-LIVE-DATA-001")
live_initial = self.chat_client.get("/api/public/chat/requests/TRK-LIVE-DATA-001/live", cookies=cookies)
self.assertEqual(live_initial.status_code, 200)
cursor = str(live_initial.json().get("cursor") or "")
self.assertTrue(bool(cursor))
live_no_delta = self.chat_client.get(
"/api/public/chat/requests/TRK-LIVE-DATA-001/live",
params={"cursor": cursor},
cookies=cookies,
)
self.assertEqual(live_no_delta.status_code, 200)
self.assertFalse(bool(live_no_delta.json().get("has_updates")))
save_values = self.chat_client.post(
f"/api/public/chat/requests/TRK-LIVE-DATA-001/data-requests/{message_id}",
cookies=cookies,
json={"items": [{"id": row_id, "value_text": "1234"}]},
)
self.assertEqual(save_values.status_code, 200)
self.assertEqual(int(save_values.json().get("updated") or 0), 1)
live_after_fill = self.chat_client.get(
"/api/public/chat/requests/TRK-LIVE-DATA-001/live",
params={"cursor": cursor},
cookies=cookies,
)
self.assertEqual(live_after_fill.status_code, 200)
self.assertTrue(bool(live_after_fill.json().get("has_updates")))
def test_public_cabinet_respects_track_access(self): def test_public_cabinet_respects_track_access(self):
with self.SessionLocal() as db: with self.SessionLocal() as db:
req = Request( req = Request(

View file

@ -26,6 +26,7 @@ from app.models.attachment import Attachment
from app.models.message import Message from app.models.message import Message
from app.models.notification import Notification from app.models.notification import Notification
from app.models.request import Request from app.models.request import Request
from app.services.s3_storage import S3Storage
class _FakeBody: class _FakeBody:
@ -770,3 +771,32 @@ class UploadsS3Tests(unittest.TestCase):
response = self.client.get(f"/api/admin/uploads/object/{key}?token={token}") response = self.client.get(f"/api/admin/uploads/object/{key}?token={token}")
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
self.assertIn("антивирус", str(response.json().get("detail", "")).lower()) self.assertIn("антивирус", str(response.json().get("detail", "")).lower())
def test_s3_storage_allows_presign_when_head_bucket_forbidden(self):
class _S3ClientWithForbiddenHeadBucket:
def __init__(self):
self.head_bucket_calls = 0
self.create_bucket_calls = 0
def head_bucket(self, **kwargs):
self.head_bucket_calls += 1
raise ClientError({"Error": {"Code": "403", "Message": "Forbidden"}}, "HeadBucket")
def create_bucket(self, **kwargs):
self.create_bucket_calls += 1
return {}
def generate_presigned_url(self, operation_name, Params=None, ExpiresIn=900, HttpMethod="PUT"):
key = str((Params or {}).get("Key") or "file.bin")
return f"https://s3.local/{settings.S3_BUCKET}/{key}?expires={ExpiresIn}"
fake_client = _S3ClientWithForbiddenHeadBucket()
with patch("app.services.s3_storage.boto3.client", return_value=fake_client):
storage = S3Storage()
first = storage.create_presigned_put_url("avatars/test-user/photo.png", "image/png")
second = storage.create_presigned_put_url("avatars/test-user/photo-2.png", "image/png")
self.assertTrue(first.startswith("/s3/"))
self.assertTrue(second.startswith("/s3/"))
self.assertEqual(fake_client.head_bucket_calls, 1)
self.assertEqual(fake_client.create_bucket_calls, 0)