mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
fix upload files
This commit is contained in:
parent
e2dbf530bd
commit
94522c0747
13 changed files with 471 additions and 22 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue