diff --git a/app/api/admin/crud_modules/service.py b/app/api/admin/crud_modules/service.py index 6448d0e..a7b73da 100644 --- a/app/api/admin/crud_modules/service.py +++ b/app/api/admin/crud_modules/service.py @@ -36,6 +36,7 @@ from app.services.request_read_markers import ( mark_unread_for_client, mark_unread_for_lawyer, ) +from app.services.request_deadline import initial_important_date_at from app.services.request_status import apply_status_change_effects from app.services.request_templates import validate_required_topic_fields_or_400 from app.services.status_flow import transition_allowed_for_topic @@ -349,6 +350,9 @@ def create_row_service(table_name: str, payload: dict[str, Any], db: Session, ad prepared["assigned_lawyer_id"] = str(assigned_lawyer.id) if prepared.get("effective_rate") is None: prepared["effective_rate"] = assigned_lawyer.default_rate + important_raw = prepared.get("important_date_at") + if important_raw is None or not str(important_raw).strip(): + prepared["important_date_at"] = initial_important_date_at() if normalized == "invoices": req = _request_for_uuid_or_400(db, prepared.get("request_id")) prepared["request_id"] = req.id diff --git a/app/api/admin/requests_modules/service.py b/app/api/admin/requests_modules/service.py index 0f94555..cd2d488 100644 --- a/app/api/admin/requests_modules/service.py +++ b/app/api/admin/requests_modules/service.py @@ -32,6 +32,7 @@ from app.services.request_read_markers import ( mark_unread_for_client, mark_unread_for_lawyer, ) +from app.services.request_deadline import initial_important_date_at from app.services.request_status import apply_status_change_effects from app.services.request_templates import validate_required_topic_fields_or_400 from app.services.status_flow import transition_allowed_for_topic @@ -184,6 +185,7 @@ def create_request_service(payload: RequestAdminCreate, db: Session, admin: dict assigned_lawyer_id = str(assigned_lawyer.id) if effective_rate is None: effective_rate = assigned_lawyer.default_rate + important_date_at = payload.important_date_at or initial_important_date_at() row = Request( track_number=track, client_id=client.id, @@ -191,7 +193,7 @@ def create_request_service(payload: RequestAdminCreate, db: Session, admin: dict client_phone=client.phone, topic_code=payload.topic_code, status_code=payload.status_code, - important_date_at=payload.important_date_at, + important_date_at=important_date_at, description=payload.description, extra_fields=payload.extra_fields, assigned_lawyer_id=assigned_lawyer_id, diff --git a/app/api/admin/uploads.py b/app/api/admin/uploads.py index e9f185d..27d0003 100644 --- a/app/api/admin/uploads.py +++ b/app/api/admin/uploads.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io import uuid from typing import Tuple @@ -7,6 +8,7 @@ from botocore.exceptions import ClientError from fastapi import APIRouter, Depends, HTTPException, Query, Request as FastapiRequest from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session +from PIL import Image, ImageOps, UnidentifiedImageError from app.core.config import settings from app.core.deps import require_role @@ -30,6 +32,10 @@ from app.services.s3_storage import build_object_key, get_s3_storage router = APIRouter() +AVATAR_MAX_SIZE_PX = 512 +AVATAR_WEBP_QUALITY = 80 +_AVATAR_RESAMPLE = getattr(getattr(Image, "Resampling", Image), "LANCZOS", 1) + def _max_file_bytes() -> int: return int(settings.MAX_FILE_MB) * 1024 * 1024 @@ -97,6 +103,68 @@ def _client_ip(http_request: FastapiRequest) -> str | None: return None +def _read_object_bytes_or_400(storage, key: str) -> bytes: + try: + obj = storage.get_object(key) + except ClientError: + raise HTTPException(status_code=400, detail="Файл не найден в хранилище") + body = obj.get("Body") + if hasattr(body, "read"): + data = body.read() + elif hasattr(body, "iter_chunks"): + data = b"".join(body.iter_chunks()) + else: + raise HTTPException(status_code=500, detail="Не удалось прочитать объект из хранилища") + if isinstance(data, str): + data = data.encode("utf-8") + if not isinstance(data, (bytes, bytearray)) or not data: + raise HTTPException(status_code=400, detail="Пустой файл аватара") + return bytes(data) + + +def _write_object_bytes_or_500(storage, *, key: str, content: bytes, mime_type: str) -> None: + if hasattr(storage, "client") and hasattr(storage.client, "put_object") and hasattr(storage, "bucket"): + storage.client.put_object( + Bucket=storage.bucket, + Key=key, + Body=content, + ContentType=mime_type, + ) + return + objects = getattr(storage, "objects", None) + if isinstance(objects, dict): + objects[key] = { + "size": int(len(content)), + "mime": str(mime_type or "application/octet-stream"), + "content": bytes(content), + } + return + raise HTTPException(status_code=500, detail="Хранилище не поддерживает запись объектов") + + +def _normalize_avatar_to_webp_or_400(storage, *, key: str) -> tuple[int, str]: + source = _read_object_bytes_or_400(storage, key) + try: + with Image.open(io.BytesIO(source)) as image: + image = ImageOps.exif_transpose(image) + image.load() + if max(image.size) > AVATAR_MAX_SIZE_PX: + image.thumbnail((AVATAR_MAX_SIZE_PX, AVATAR_MAX_SIZE_PX), resample=_AVATAR_RESAMPLE) + if image.mode != "RGB": + image = image.convert("RGB") + out = io.BytesIO() + image.save(out, format="WEBP", quality=AVATAR_WEBP_QUALITY, method=6) + optimized = out.getvalue() + except UnidentifiedImageError: + raise HTTPException(status_code=400, detail="Аватар должен быть изображением") + except OSError: + raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара") + if not optimized: + raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара") + _write_object_bytes_or_500(storage, key=key, content=optimized, mime_type="image/webp") + return int(len(optimized)), "image/webp" + + @router.post("/init", response_model=UploadInitResponse) def upload_init( payload: UploadInitPayload, @@ -139,6 +207,8 @@ def upload_init( return response if payload.scope == UploadScope.USER_AVATAR: + if not str(payload.mime_type or "").strip().lower().startswith("image/"): + raise HTTPException(status_code=400, detail="Для аватара поддерживаются только изображения") target_user_id = str(payload.user_id or actor_id) target_uuid = _uuid_or_400(target_user_id, "user_id") if role != "ADMIN" and str(target_uuid) != actor_id: @@ -275,6 +345,8 @@ def upload_complete( return UploadCompleteResponse(status="ok", attachment_id=str(row.id)) if payload.scope == UploadScope.USER_AVATAR: + if not str(payload.mime_type or "").strip().lower().startswith("image/"): + raise HTTPException(status_code=400, detail="Для аватара поддерживаются только изображения") target_user_id = str(payload.user_id or actor_id) target_uuid = _uuid_or_400(target_user_id, "user_id") if role != "ADMIN" and str(target_uuid) != actor_id: @@ -283,6 +355,7 @@ def upload_complete( if user is None: raise HTTPException(status_code=404, detail="Пользователь не найден") _ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/") + optimized_size, optimized_mime = _normalize_avatar_to_webp_or_400(storage, key=payload.key) user.avatar_url = f"s3://{payload.key}" user.responsible = responsible db.add(user) @@ -295,7 +368,12 @@ def upload_complete( scope=scope_name, allowed=True, object_key=payload.key, - details={"mime_type": payload.mime_type, "size_bytes": int(actual_size)}, + details={ + "mime_type": optimized_mime, + "size_bytes": int(optimized_size), + "source_mime_type": payload.mime_type, + "source_size_bytes": int(actual_size), + }, responsible=responsible, ) db.commit() diff --git a/app/api/public/requests.py b/app/api/public/requests.py index bfd5edd..b3cb0eb 100644 --- a/app/api/public/requests.py +++ b/app/api/public/requests.py @@ -38,6 +38,7 @@ from app.services.notifications import ( unread_client_summary, ) from app.services.request_read_markers import clear_unread_for_client +from app.services.request_deadline import initial_important_date_at from app.services.request_templates import validate_required_topic_fields_or_400 from app.services.security_audit import extract_client_ip, record_pii_access_event from app.api.admin.requests_modules.status_flow import get_request_status_route_service @@ -285,6 +286,7 @@ def create_request( client_phone=client.phone, client_email=client.email, topic_code=payload.topic_code, + important_date_at=initial_important_date_at(), description=payload.description, extra_fields=payload.extra_fields, pdn_consent=True, diff --git a/app/services/request_deadline.py b/app/services/request_deadline.py new file mode 100644 index 0000000..ec835a5 --- /dev/null +++ b/app/services/request_deadline.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +INITIAL_REQUEST_SLA_HOURS = 24 + + +def initial_important_date_at(*, now: datetime | None = None) -> datetime: + base = now or datetime.now(timezone.utc) + if base.tzinfo is None: + base = base.replace(tzinfo=timezone.utc) + else: + base = base.astimezone(timezone.utc) + return base + timedelta(hours=INITIAL_REQUEST_SLA_HOURS) diff --git a/app/web/admin.css b/app/web/admin.css index 3e604f2..d1027d9 100644 --- a/app/web/admin.css +++ b/app/web/admin.css @@ -3015,6 +3015,19 @@ } } + @media (max-width: 760px) { + .request-workspace-layout { + display: flex; + flex-direction: column; + } + .request-workspace-layout > .request-chat-block { + order: 1; + } + .request-main-column { + order: 2; + } + } + @media (max-width: 620px) { .cards, .filters { diff --git a/app/web/client.css b/app/web/client.css index e84abf2..2e63ccf 100644 --- a/app/web/client.css +++ b/app/web/client.css @@ -8,8 +8,24 @@ padding: 1rem 0 1.6rem; } +.client-topbar-copy { + min-width: 0; + display: grid; + gap: 0.35rem; +} + .client-topbar p { - margin: 0.35rem 0 0; + margin: 0; +} + +.client-help-inline { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +.client-help-inline p { + white-space: nowrap; } .client-title-row { @@ -285,6 +301,10 @@ width: calc(100% - 1rem); } + .client-help-inline { + max-width: 100%; + } + .client-request-toolbar { flex-direction: column; align-items: stretch; diff --git a/app/web/client.jsx b/app/web/client.jsx index 503c063..257b313 100644 --- a/app/web/client.jsx +++ b/app/web/client.jsx @@ -922,7 +922,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
-
+

@@ -933,19 +933,21 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad {viewerFullName}

-

Мы рады помочь Вам

+
+

Мы рады вам помочь

+ +
-
diff --git a/requirements.txt b/requirements.txt index fecd480..be1e761 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ boto3==1.35.70 httpx==0.27.2 python-multipart==0.0.22 smsaero-api-async +Pillow==11.2.1 diff --git a/tests/admin/test_assignment_users.py b/tests/admin/test_assignment_users.py index 633ea6d..cd9786d 100644 --- a/tests/admin/test_assignment_users.py +++ b/tests/admin/test_assignment_users.py @@ -172,6 +172,63 @@ class AdminAssignmentAndUsersTests(AdminUniversalCrudBase): actions = [event.action for event in events] self.assertIn("MANUAL_REASSIGN", actions) + def test_new_request_gets_initial_important_date_plus_24h(self): + headers = self._auth_headers("ADMIN", email="root@example.com") + before_create = datetime.now(timezone.utc) + + legacy_created = self.client.post( + "/api/admin/requests", + headers=headers, + json={ + "client_name": "Legacy deadline", + "client_phone": "+79990007701", + "status_code": "NEW", + "description": "legacy create deadline", + }, + ) + self.assertEqual(legacy_created.status_code, 201) + legacy_id = legacy_created.json()["id"] + + crud_created = self.client.post( + "/api/admin/crud/requests", + headers=headers, + json={ + "client_name": "CRUD deadline", + "client_phone": "+79990007702", + "status_code": "NEW", + "description": "crud create deadline", + }, + ) + self.assertEqual(crud_created.status_code, 201) + crud_id = crud_created.json()["id"] + + after_create = datetime.now(timezone.utc) + + def _to_utc(value: datetime | None) -> datetime: + if value is None: + return datetime.now(timezone.utc) + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + with self.SessionLocal() as db: + legacy_row = db.get(Request, UUID(legacy_id)) + crud_row = db.get(Request, UUID(crud_id)) + self.assertIsNotNone(legacy_row) + self.assertIsNotNone(crud_row) + self.assertIsNotNone(legacy_row.important_date_at) + self.assertIsNotNone(crud_row.important_date_at) + + legacy_deadline = _to_utc(legacy_row.important_date_at) + crud_deadline = _to_utc(crud_row.important_date_at) + lower_bound = before_create + timedelta(hours=23) + upper_bound = after_create + timedelta(hours=25) + + self.assertGreaterEqual(legacy_deadline, lower_bound) + self.assertLessEqual(legacy_deadline, upper_bound) + self.assertGreaterEqual(crud_deadline, lower_bound) + self.assertLessEqual(crud_deadline, upper_bound) + def test_reassign_is_admin_only_and_validates_request_state(self): with self.SessionLocal() as db: lawyer1 = AdminUser( diff --git a/tests/test_public_requests.py b/tests/test_public_requests.py index 08e9a1b..55b54a7 100644 --- a/tests/test_public_requests.py +++ b/tests/test_public_requests.py @@ -1,6 +1,6 @@ import os import unittest -from datetime import timedelta +from datetime import datetime, timedelta, timezone from unittest.mock import patch from uuid import UUID, uuid4 @@ -146,11 +146,21 @@ class PublicRequestCreateTests(unittest.TestCase): self.assertEqual(created.description, payload["description"]) self.assertEqual(created.extra_fields, payload["extra_fields"]) self.assertEqual(created.status_code, "NEW") + self.assertIsNotNone(created.important_date_at) self.assertEqual(created.track_number, body["track_number"]) self.assertEqual(created.responsible, "Клиент") self.assertTrue(created.pdn_consent) self.assertIsNotNone(created.pdn_consent_at) self.assertIsNotNone(created.pdn_consent_ip) + created_at = created.created_at or datetime.now(timezone.utc) + important_at = created.important_date_at + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + if important_at.tzinfo is None: + important_at = important_at.replace(tzinfo=timezone.utc) + initial_deadline_seconds = (important_at - created_at).total_seconds() + self.assertGreaterEqual(initial_deadline_seconds, 23 * 3600) + self.assertLessEqual(initial_deadline_seconds, 25 * 3600) client = db.get(Client, created.client_id) self.assertIsNotNone(client) self.assertEqual(client.phone, payload["client_phone"]) diff --git a/tests/test_uploads_s3.py b/tests/test_uploads_s3.py index 9b4a012..d23e64a 100644 --- a/tests/test_uploads_s3.py +++ b/tests/test_uploads_s3.py @@ -1,4 +1,5 @@ import os +import base64 import unittest from datetime import timedelta from uuid import UUID, uuid4 @@ -28,6 +29,10 @@ from app.models.notification import Notification from app.models.request import Request from app.services.s3_storage import S3Storage +_AVATAR_PNG_1X1 = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+T5kAAAAASUVORK5CYII=" +) + class _FakeBody: def __init__(self, payload: bytes): @@ -145,7 +150,7 @@ class UploadsS3Tests(unittest.TestCase): key = init_resp.json()["key"] self.assertTrue(key.startswith("avatars/")) - fake_s3.objects[key] = {"size": 2048, "mime": "image/png", "content": b"x" * 2048} + fake_s3.objects[key] = {"size": len(_AVATAR_PNG_1X1), "mime": "image/png", "content": _AVATAR_PNG_1X1} done_resp = self.client.post( "/api/admin/uploads/complete", headers=headers, @@ -164,13 +169,60 @@ class UploadsS3Tests(unittest.TestCase): token = headers["Authorization"].replace("Bearer ", "") view_resp = self.client.get(f"/api/admin/uploads/object/{key}?token={token}") self.assertEqual(view_resp.status_code, 200) - self.assertEqual(view_resp.content, b"x" * 2048) + self.assertNotEqual(view_resp.content, _AVATAR_PNG_1X1) + self.assertIn("image/webp", view_resp.headers.get("content-type", "")) with self.SessionLocal() as db: refreshed = db.get(AdminUser, UUID(user_id)) self.assertIsNotNone(refreshed) self.assertEqual(refreshed.avatar_url, f"s3://{key}") + def test_admin_avatar_upload_rejects_non_image_content(self): + fake_s3 = _FakeS3Storage() + with self.SessionLocal() as db: + user = AdminUser( + role="LAWYER", + name="Юрист Без Картинки", + email="avatar-invalid@example.com", + password_hash="hash", + is_active=True, + ) + db.add(user) + db.commit() + user_id = str(user.id) + + headers = self._admin_headers(sub=user_id, role="LAWYER", email="avatar-invalid@example.com") + with patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3): + init_resp = self.client.post( + "/api/admin/uploads/init", + headers=headers, + json={ + "file_name": "photo.png", + "mime_type": "image/png", + "size_bytes": 128, + "scope": "USER_AVATAR", + "user_id": user_id, + }, + ) + self.assertEqual(init_resp.status_code, 200) + key = init_resp.json()["key"] + fake_s3.objects[key] = {"size": 128, "mime": "image/png", "content": b"not-an-image-content"} + + done_resp = self.client.post( + "/api/admin/uploads/complete", + headers=headers, + json={ + "key": key, + "file_name": "photo.png", + "mime_type": "image/png", + "size_bytes": 128, + "scope": "USER_AVATAR", + "user_id": user_id, + }, + ) + self.assertEqual(done_resp.status_code, 400) + self.assertIn("изображением", str(done_resp.json().get("detail", "")).lower()) + def test_public_request_attachment_upload_flow_creates_attachment(self): fake_s3 = _FakeS3Storage() with self.SessionLocal() as db: