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:
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue