mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 18:13:46 +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_client,
|
||||||
mark_unread_for_lawyer,
|
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_status import apply_status_change_effects
|
||||||
from app.services.request_templates import validate_required_topic_fields_or_400
|
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||||
from app.services.status_flow import transition_allowed_for_topic
|
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)
|
prepared["assigned_lawyer_id"] = str(assigned_lawyer.id)
|
||||||
if prepared.get("effective_rate") is None:
|
if prepared.get("effective_rate") is None:
|
||||||
prepared["effective_rate"] = assigned_lawyer.default_rate
|
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":
|
if normalized == "invoices":
|
||||||
req = _request_for_uuid_or_400(db, prepared.get("request_id"))
|
req = _request_for_uuid_or_400(db, prepared.get("request_id"))
|
||||||
prepared["request_id"] = req.id
|
prepared["request_id"] = req.id
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ from app.services.request_read_markers import (
|
||||||
mark_unread_for_client,
|
mark_unread_for_client,
|
||||||
mark_unread_for_lawyer,
|
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_status import apply_status_change_effects
|
||||||
from app.services.request_templates import validate_required_topic_fields_or_400
|
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||||
from app.services.status_flow import transition_allowed_for_topic
|
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)
|
assigned_lawyer_id = str(assigned_lawyer.id)
|
||||||
if effective_rate is None:
|
if effective_rate is None:
|
||||||
effective_rate = assigned_lawyer.default_rate
|
effective_rate = assigned_lawyer.default_rate
|
||||||
|
important_date_at = payload.important_date_at or initial_important_date_at()
|
||||||
row = Request(
|
row = Request(
|
||||||
track_number=track,
|
track_number=track,
|
||||||
client_id=client.id,
|
client_id=client.id,
|
||||||
|
|
@ -191,7 +193,7 @@ def create_request_service(payload: RequestAdminCreate, db: Session, admin: dict
|
||||||
client_phone=client.phone,
|
client_phone=client.phone,
|
||||||
topic_code=payload.topic_code,
|
topic_code=payload.topic_code,
|
||||||
status_code=payload.status_code,
|
status_code=payload.status_code,
|
||||||
important_date_at=payload.important_date_at,
|
important_date_at=important_date_at,
|
||||||
description=payload.description,
|
description=payload.description,
|
||||||
extra_fields=payload.extra_fields,
|
extra_fields=payload.extra_fields,
|
||||||
assigned_lawyer_id=assigned_lawyer_id,
|
assigned_lawyer_id=assigned_lawyer_id,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Tuple
|
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 import APIRouter, Depends, HTTPException, Query, Request as FastapiRequest
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.deps import require_role
|
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()
|
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:
|
def _max_file_bytes() -> int:
|
||||||
return int(settings.MAX_FILE_MB) * 1024 * 1024
|
return int(settings.MAX_FILE_MB) * 1024 * 1024
|
||||||
|
|
@ -97,6 +103,68 @@ def _client_ip(http_request: FastapiRequest) -> str | None:
|
||||||
return 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)
|
@router.post("/init", response_model=UploadInitResponse)
|
||||||
def upload_init(
|
def upload_init(
|
||||||
payload: UploadInitPayload,
|
payload: UploadInitPayload,
|
||||||
|
|
@ -139,6 +207,8 @@ def upload_init(
|
||||||
return response
|
return response
|
||||||
|
|
||||||
if payload.scope == UploadScope.USER_AVATAR:
|
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_user_id = str(payload.user_id or actor_id)
|
||||||
target_uuid = _uuid_or_400(target_user_id, "user_id")
|
target_uuid = _uuid_or_400(target_user_id, "user_id")
|
||||||
if role != "ADMIN" and str(target_uuid) != actor_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))
|
return UploadCompleteResponse(status="ok", attachment_id=str(row.id))
|
||||||
|
|
||||||
if payload.scope == UploadScope.USER_AVATAR:
|
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_user_id = str(payload.user_id or actor_id)
|
||||||
target_uuid = _uuid_or_400(target_user_id, "user_id")
|
target_uuid = _uuid_or_400(target_user_id, "user_id")
|
||||||
if role != "ADMIN" and str(target_uuid) != actor_id:
|
if role != "ADMIN" and str(target_uuid) != actor_id:
|
||||||
|
|
@ -283,6 +355,7 @@ def upload_complete(
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||||
_ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/")
|
_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.avatar_url = f"s3://{payload.key}"
|
||||||
user.responsible = responsible
|
user.responsible = responsible
|
||||||
db.add(user)
|
db.add(user)
|
||||||
|
|
@ -295,7 +368,12 @@ def upload_complete(
|
||||||
scope=scope_name,
|
scope=scope_name,
|
||||||
allowed=True,
|
allowed=True,
|
||||||
object_key=payload.key,
|
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,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ from app.services.notifications import (
|
||||||
unread_client_summary,
|
unread_client_summary,
|
||||||
)
|
)
|
||||||
from app.services.request_read_markers import clear_unread_for_client
|
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.request_templates import validate_required_topic_fields_or_400
|
||||||
from app.services.security_audit import extract_client_ip, record_pii_access_event
|
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
|
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_phone=client.phone,
|
||||||
client_email=client.email,
|
client_email=client.email,
|
||||||
topic_code=payload.topic_code,
|
topic_code=payload.topic_code,
|
||||||
|
important_date_at=initial_important_date_at(),
|
||||||
description=payload.description,
|
description=payload.description,
|
||||||
extra_fields=payload.extra_fields,
|
extra_fields=payload.extra_fields,
|
||||||
pdn_consent=True,
|
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) {
|
@media (max-width: 620px) {
|
||||||
.cards,
|
.cards,
|
||||||
.filters {
|
.filters {
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,24 @@
|
||||||
padding: 1rem 0 1.6rem;
|
padding: 1rem 0 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-topbar-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.client-topbar p {
|
.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 {
|
.client-title-row {
|
||||||
|
|
@ -285,6 +301,10 @@
|
||||||
width: calc(100% - 1rem);
|
width: calc(100% - 1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-help-inline {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.client-request-toolbar {
|
.client-request-toolbar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|
|
||||||
|
|
@ -922,7 +922,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
<div className="client-page-shell">
|
<div className="client-page-shell">
|
||||||
<main className="main client-main">
|
<main className="main client-main">
|
||||||
<div className="topbar client-topbar">
|
<div className="topbar client-topbar">
|
||||||
<div>
|
<div className="client-topbar-copy">
|
||||||
<div className="client-title-row">
|
<div className="client-title-row">
|
||||||
<img className="brand-mark" src="/brand-mark.svg" alt="" width="24" height="24" />
|
<img className="brand-mark" src="/brand-mark.svg" alt="" width="24" height="24" />
|
||||||
<h1>
|
<h1>
|
||||||
|
|
@ -933,8 +933,8 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
<span className="client-title-user">{viewerFullName}</span>
|
<span className="client-title-user">{viewerFullName}</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">Мы рады помочь Вам</p>
|
<div className="client-help-inline">
|
||||||
</div>
|
<p className="muted">Мы рады вам помочь</p>
|
||||||
<button
|
<button
|
||||||
className="icon-btn workspace-head-icon"
|
className="icon-btn workspace-head-icon"
|
||||||
id="cabinet-help-open"
|
id="cabinet-help-open"
|
||||||
|
|
@ -947,6 +947,8 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
?
|
?
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="section active client-section">
|
<section className="section active client-section">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,4 @@ boto3==1.35.70
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
python-multipart==0.0.22
|
python-multipart==0.0.22
|
||||||
smsaero-api-async
|
smsaero-api-async
|
||||||
|
Pillow==11.2.1
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,63 @@ class AdminAssignmentAndUsersTests(AdminUniversalCrudBase):
|
||||||
actions = [event.action for event in events]
|
actions = [event.action for event in events]
|
||||||
self.assertIn("MANUAL_REASSIGN", actions)
|
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):
|
def test_reassign_is_admin_only_and_validates_request_state(self):
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
lawyer1 = AdminUser(
|
lawyer1 = AdminUser(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
|
@ -146,11 +146,21 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
self.assertEqual(created.description, payload["description"])
|
self.assertEqual(created.description, payload["description"])
|
||||||
self.assertEqual(created.extra_fields, payload["extra_fields"])
|
self.assertEqual(created.extra_fields, payload["extra_fields"])
|
||||||
self.assertEqual(created.status_code, "NEW")
|
self.assertEqual(created.status_code, "NEW")
|
||||||
|
self.assertIsNotNone(created.important_date_at)
|
||||||
self.assertEqual(created.track_number, body["track_number"])
|
self.assertEqual(created.track_number, body["track_number"])
|
||||||
self.assertEqual(created.responsible, "Клиент")
|
self.assertEqual(created.responsible, "Клиент")
|
||||||
self.assertTrue(created.pdn_consent)
|
self.assertTrue(created.pdn_consent)
|
||||||
self.assertIsNotNone(created.pdn_consent_at)
|
self.assertIsNotNone(created.pdn_consent_at)
|
||||||
self.assertIsNotNone(created.pdn_consent_ip)
|
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)
|
client = db.get(Client, created.client_id)
|
||||||
self.assertIsNotNone(client)
|
self.assertIsNotNone(client)
|
||||||
self.assertEqual(client.phone, payload["client_phone"])
|
self.assertEqual(client.phone, payload["client_phone"])
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import os
|
import os
|
||||||
|
import base64
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
@ -28,6 +29,10 @@ from app.models.notification import Notification
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.services.s3_storage import S3Storage
|
from app.services.s3_storage import S3Storage
|
||||||
|
|
||||||
|
_AVATAR_PNG_1X1 = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+T5kAAAAASUVORK5CYII="
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _FakeBody:
|
class _FakeBody:
|
||||||
def __init__(self, payload: bytes):
|
def __init__(self, payload: bytes):
|
||||||
|
|
@ -145,7 +150,7 @@ class UploadsS3Tests(unittest.TestCase):
|
||||||
key = init_resp.json()["key"]
|
key = init_resp.json()["key"]
|
||||||
self.assertTrue(key.startswith("avatars/"))
|
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(
|
done_resp = self.client.post(
|
||||||
"/api/admin/uploads/complete",
|
"/api/admin/uploads/complete",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
|
|
@ -164,13 +169,60 @@ class UploadsS3Tests(unittest.TestCase):
|
||||||
token = headers["Authorization"].replace("Bearer ", "")
|
token = headers["Authorization"].replace("Bearer ", "")
|
||||||
view_resp = self.client.get(f"/api/admin/uploads/object/{key}?token={token}")
|
view_resp = self.client.get(f"/api/admin/uploads/object/{key}?token={token}")
|
||||||
self.assertEqual(view_resp.status_code, 200)
|
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:
|
with self.SessionLocal() as db:
|
||||||
refreshed = db.get(AdminUser, UUID(user_id))
|
refreshed = db.get(AdminUser, UUID(user_id))
|
||||||
self.assertIsNotNone(refreshed)
|
self.assertIsNotNone(refreshed)
|
||||||
self.assertEqual(refreshed.avatar_url, f"s3://{key}")
|
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):
|
def test_public_request_attachment_upload_flow_creates_attachment(self):
|
||||||
fake_s3 = _FakeS3Storage()
|
fake_s3 = _FakeS3Storage()
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue