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:
if row.key not in touched_keys:
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)
req.responsible = responsible
db.add(req)

View file

@ -5,6 +5,7 @@ import uuid
from typing import Any
from fastapi import HTTPException
from sqlalchemy import func
from sqlalchemy.inspection import inspect as sa_inspect
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
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]:
data = dict(payload)
if table_name == "topics" and not str(data.get("code") or "").strip():
base = _slugify(str(data.get("name") or ""), "topic")
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():
base = _slugify(str(data.get("name") or ""), "status")
data["code"] = _make_unique_value(db, model, "code", base)

View file

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

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import uuid
from typing import Any
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
MAX_CHAT_MESSAGE_LEN = 12_000
CHAT_PARTICIPANT_ADMIN_IDS_KEY = "chat_participant_admin_ids"
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() + "..."
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]]:
message_ids = []
for row in rows:
@ -212,6 +246,7 @@ def create_admin_or_lawyer_message(
body=message_body,
responsible=responsible,
)
_register_chat_participant(request, actor_admin_user_id)
normalized_event = str(event_type or EVENT_MESSAGE).strip().upper() or EVENT_MESSAGE
mark_unread_for_client(request, normalized_event)
request.responsible = responsible

View file

@ -33,6 +33,7 @@ _EVENT_LABELS = {
EVENT_ASSIGNMENT: "Заявка назначена",
EVENT_REASSIGNMENT: "Заявка переназначена",
}
CHAT_PARTICIPANT_ADMIN_IDS_KEY = "chat_participant_admin_ids"
def _as_utc_now() -> datetime:
@ -93,6 +94,19 @@ def _active_admin_ids(db: Session, *, exclude_admin_user_id: uuid.UUID | None =
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(
db: Session,
*,
@ -237,10 +251,55 @@ def notify_request_event(
if row is not None:
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 actor == "CLIENT":
_notify_lawyer_if_any()
_notify_admins()
_notify_chat_participant_lawyers()
else:
_notify_client()
elif event == EVENT_STATUS:

View file

@ -46,6 +46,11 @@ class S3Storage:
self.client.head_bucket(Bucket=self.bucket)
except ClientError as exc:
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"}:
raise
kwargs: dict = {"Bucket": self.bucket}

View file

@ -1303,6 +1303,16 @@
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 {
min-width: 640px;
}
@ -3012,6 +3022,12 @@
width: 100%;
margin-left: 0;
}
.config-floating-actions {
position: static;
justify-content: flex-end;
width: 100%;
margin-bottom: 0.5rem;
}
.topbar {
flex-direction: column;
align-items: flex-start;

View file

@ -107,6 +107,28 @@ export function ConfigSection(props) {
<div className="config-layout">
<div className="config-panel config-panel-flat">
<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
filters={activeConfigTableState.filters}
onOpen={() => openFilterModal(configActiveKey)}
@ -616,26 +638,6 @@ export function ConfigSection(props) {
>
<RefreshIcon />
</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
className="btn secondary table-control-btn"
type="button"

View file

@ -331,6 +331,24 @@ class AdminAssignmentAndUsersTests(AdminUniversalCrudBase):
self.assertRegex(body2["code"], r"^[a-z0-9-]+$")
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):
headers = self._auth_headers("ADMIN", email="root@example.com")
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)
self.assertEqual(deleted.status_code, 200)

View file

@ -538,3 +538,89 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
self.assertEqual(own_live_with_typing.status_code, 200)
typing_rows = own_live_with_typing.json().get("typing") or []
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_history import StatusHistory
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.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(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):
@classmethod

View file

@ -1,6 +1,6 @@
import os
import unittest
from datetime import timedelta
from datetime import datetime, timedelta, timezone
from uuid import UUID
from unittest.mock import patch
@ -377,6 +377,74 @@ class PublicCabinetTests(unittest.TestCase):
self.assertEqual(typing_on.status_code, 200)
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):
with self.SessionLocal() as db:
req = Request(

View file

@ -26,6 +26,7 @@ from app.models.attachment import Attachment
from app.models.message import Message
from app.models.notification import Notification
from app.models.request import Request
from app.services.s3_storage import S3Storage
class _FakeBody:
@ -770,3 +771,32 @@ class UploadsS3Tests(unittest.TestCase):
response = self.client.get(f"/api/admin/uploads/object/{key}?token={token}")
self.assertEqual(response.status_code, 403)
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)