@@ -933,19 +933,21 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad {viewerFullName}
Мы рады помочь Вам
+Мы рады вам помочь
+ +From bc5db5ce358bd178bebbbcf22ce7708beaf76688 Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:39:15 +0300 Subject: [PATCH] fix user UI 6 --- app/api/admin/crud_modules/service.py | 4 ++ app/api/admin/requests_modules/service.py | 4 +- app/api/admin/uploads.py | 80 ++++++++++++++++++++++- app/api/public/requests.py | 2 + app/services/request_deadline.py | 14 ++++ app/web/admin.css | 13 ++++ app/web/client.css | 22 ++++++- app/web/client.jsx | 28 ++++---- requirements.txt | 1 + tests/admin/test_assignment_users.py | 57 ++++++++++++++++ tests/test_public_requests.py | 12 +++- tests/test_uploads_s3.py | 56 +++++++++++++++- 12 files changed, 274 insertions(+), 19 deletions(-) create mode 100644 app/services/request_deadline.py 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
Мы рады помочь Вам
+Мы рады вам помочь
+ +