fix user UI 6

This commit is contained in:
TronoSfera 2026-03-02 23:39:15 +03:00
parent 24df50e68a
commit bc5db5ce35
12 changed files with 274 additions and 19 deletions

View file

@ -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

View file

@ -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,

View file

@ -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()

View file

@ -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,

View file

@ -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)

View file

@ -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 {

View file

@ -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;

View file

@ -922,7 +922,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
<div className="client-page-shell">
<main className="main client-main">
<div className="topbar client-topbar">
<div>
<div className="client-topbar-copy">
<div className="client-title-row">
<img className="brand-mark" src="/brand-mark.svg" alt="" width="24" height="24" />
<h1>
@ -933,19 +933,21 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
<span className="client-title-user">{viewerFullName}</span>
</h1>
</div>
<p className="muted">Мы рады помочь Вам</p>
<div className="client-help-inline">
<p className="muted">Мы рады вам помочь</p>
<button
className="icon-btn workspace-head-icon"
id="cabinet-help-open"
type="button"
data-tooltip="Помощь и обращения"
aria-label="Помощь и обращения"
disabled={!canInteract}
onClick={openClientHelpModal}
>
?
</button>
</div>
</div>
<button
className="icon-btn workspace-head-icon"
id="cabinet-help-open"
type="button"
data-tooltip="Помощь и обращения"
aria-label="Помощь и обращения"
disabled={!canInteract}
onClick={openClientHelpModal}
>
?
</button>
</div>
<section className="section active client-section">

View file

@ -14,3 +14,4 @@ boto3==1.35.70
httpx==0.27.2
python-multipart==0.0.22
smsaero-api-async
Pillow==11.2.1

View file

@ -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(

View file

@ -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"])

View file

@ -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: