mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
fix user UI 6
This commit is contained in:
parent
24df50e68a
commit
bc5db5ce35
12 changed files with 274 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
14
app/services/request_deadline.py
Normal file
14
app/services/request_deadline.py
Normal 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)
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,8 +933,8 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
|||
<span className="client-title-user">{viewerFullName}</span>
|
||||
</h1>
|
||||
</div>
|
||||
<p className="muted">Мы рады помочь Вам</p>
|
||||
</div>
|
||||
<div className="client-help-inline">
|
||||
<p className="muted">Мы рады вам помочь</p>
|
||||
<button
|
||||
className="icon-btn workspace-head-icon"
|
||||
id="cabinet-help-open"
|
||||
|
|
@ -947,6 +947,8 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
|||
?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="section active client-section">
|
||||
<div className="section-head">
|
||||
|
|
|
|||
|
|
@ -14,3 +14,4 @@ boto3==1.35.70
|
|||
httpx==0.27.2
|
||||
python-multipart==0.0.22
|
||||
smsaero-api-async
|
||||
Pillow==11.2.1
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue