From 94522c0747a5303ad4c2ee5c2430c8f48c68abd0 Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:54:09 +0300 Subject: [PATCH] fix upload files --- app/api/admin/chat.py | 3 + app/api/admin/crud_modules/payloads.py | 16 +++ app/api/public/chat.py | 2 + app/services/chat_secure_service.py | 35 ++++++ app/services/notifications.py | 59 ++++++++++ app/services/s3_storage.py | 5 + app/web/admin.css | 16 +++ .../admin/features/config/ConfigSection.jsx | 42 +++---- tests/admin/test_assignment_users.py | 19 ++- tests/admin/test_lawyer_chat.py | 86 ++++++++++++++ tests/test_notifications.py | 110 ++++++++++++++++++ tests/test_public_cabinet.py | 70 ++++++++++- tests/test_uploads_s3.py | 30 +++++ 13 files changed, 471 insertions(+), 22 deletions(-) diff --git a/app/api/admin/chat.py b/app/api/admin/chat.py index b1a2bea..33ffc23 100644 --- a/app/api/admin/chat.py +++ b/app/api/admin/chat.py @@ -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) diff --git a/app/api/admin/crud_modules/payloads.py b/app/api/admin/crud_modules/payloads.py index 8f19827..5185bd4 100644 --- a/app/api/admin/crud_modules/payloads.py +++ b/app/api/admin/crud_modules/payloads.py @@ -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) diff --git a/app/api/public/chat.py b/app/api/public/chat.py index f547626..6a58595 100644 --- a/app/api/public/chat.py +++ b/app/api/public/chat.py @@ -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( diff --git a/app/services/chat_secure_service.py b/app/services/chat_secure_service.py index 8a59242..cf4728b 100644 --- a/app/services/chat_secure_service.py +++ b/app/services/chat_secure_service.py @@ -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 diff --git a/app/services/notifications.py b/app/services/notifications.py index 4600c2a..72ff31a 100644 --- a/app/services/notifications.py +++ b/app/services/notifications.py @@ -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: diff --git a/app/services/s3_storage.py b/app/services/s3_storage.py index 3b0fcaa..09ee39a 100644 --- a/app/services/s3_storage.py +++ b/app/services/s3_storage.py @@ -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} diff --git a/app/web/admin.css b/app/web/admin.css index 52a25b9..7637b29 100644 --- a/app/web/admin.css +++ b/app/web/admin.css @@ -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; diff --git a/app/web/admin/features/config/ConfigSection.jsx b/app/web/admin/features/config/ConfigSection.jsx index b9d1062..015d6f0 100644 --- a/app/web/admin/features/config/ConfigSection.jsx +++ b/app/web/admin/features/config/ConfigSection.jsx @@ -107,6 +107,28 @@ export function ConfigSection(props) {