mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
fix speed up 03
This commit is contained in:
parent
585b6bcfc1
commit
6e2c917269
17 changed files with 439 additions and 101 deletions
45
alembic/versions/0035_add_workspace_perf_indexes.py
Normal file
45
alembic/versions/0035_add_workspace_perf_indexes.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
"""add composite indexes for request workspace payloads
|
||||||
|
|
||||||
|
Revision ID: 0035_workspace_perf_indexes
|
||||||
|
Revises: 0034_request_assigned_lawyer_idx
|
||||||
|
Create Date: 2026-03-17
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0035_workspace_perf_indexes"
|
||||||
|
down_revision = "0034_request_assigned_lawyer_idx"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(inspector: sa.Inspector, table: str, index_name: str) -> bool:
|
||||||
|
return any(str(idx.get("name")) == index_name for idx in inspector.get_indexes(table))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
if not _has_index(inspector, "messages", "ix_messages_request_created_id"):
|
||||||
|
op.create_index("ix_messages_request_created_id", "messages", ["request_id", "created_at", "id"], unique=False)
|
||||||
|
if not _has_index(inspector, "attachments", "ix_attachments_request_created_id"):
|
||||||
|
op.create_index("ix_attachments_request_created_id", "attachments", ["request_id", "created_at", "id"], unique=False)
|
||||||
|
if not _has_index(inspector, "invoices", "ix_invoices_request_issued_id"):
|
||||||
|
op.create_index("ix_invoices_request_issued_id", "invoices", ["request_id", "issued_at", "id"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
if _has_index(inspector, "invoices", "ix_invoices_request_issued_id"):
|
||||||
|
op.drop_index("ix_invoices_request_issued_id", table_name="invoices")
|
||||||
|
if _has_index(inspector, "attachments", "ix_attachments_request_created_id"):
|
||||||
|
op.drop_index("ix_attachments_request_created_id", table_name="attachments")
|
||||||
|
if _has_index(inspector, "messages", "ix_messages_request_created_id"):
|
||||||
|
op.drop_index("ix_messages_request_created_id", table_name="messages")
|
||||||
|
|
@ -365,21 +365,11 @@ def get_request_service(request_id: str, db: Session, admin: dict) -> dict[str,
|
||||||
if not req:
|
if not req:
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
ensure_lawyer_can_view_request_or_403(admin, req)
|
ensure_lawyer_can_view_request_or_403(admin, req)
|
||||||
changed = False
|
_apply_request_open_side_effects(db, req, admin, mark_chat_read=False)
|
||||||
if str(admin.get("role") or "").upper() == "LAWYER" and clear_unread_for_lawyer(req):
|
return _serialize_request_row(req)
|
||||||
changed = True
|
|
||||||
db.add(req)
|
|
||||||
read_count = mark_admin_notifications_read(
|
def _serialize_request_row(req: Request) -> dict[str, Any]:
|
||||||
db,
|
|
||||||
admin_user_id=admin.get("sub"),
|
|
||||||
request_id=req.id,
|
|
||||||
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
|
||||||
)
|
|
||||||
if read_count:
|
|
||||||
changed = True
|
|
||||||
if changed:
|
|
||||||
db.commit()
|
|
||||||
db.refresh(req)
|
|
||||||
return {
|
return {
|
||||||
"id": str(req.id),
|
"id": str(req.id),
|
||||||
"track_number": req.track_number,
|
"track_number": req.track_number,
|
||||||
|
|
@ -407,6 +397,27 @@ def get_request_service(request_id: str, db: Session, admin: dict) -> dict[str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_request_open_side_effects(db: Session, req: Request, admin: dict, *, mark_chat_read: bool) -> None:
|
||||||
|
changed = False
|
||||||
|
if str(admin.get("role") or "").upper() == "LAWYER" and clear_unread_for_lawyer(req):
|
||||||
|
changed = True
|
||||||
|
db.add(req)
|
||||||
|
read_count = mark_admin_notifications_read(
|
||||||
|
db,
|
||||||
|
admin_user_id=admin.get("sub"),
|
||||||
|
request_id=req.id,
|
||||||
|
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||||
|
)
|
||||||
|
if read_count:
|
||||||
|
changed = True
|
||||||
|
if mark_chat_read and mark_messages_read_for_staff(db, request_id=req.id, commit=False):
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
db.commit()
|
||||||
|
if str(admin.get("role") or "").upper() == "LAWYER":
|
||||||
|
db.refresh(req)
|
||||||
|
|
||||||
|
|
||||||
def _serialize_request_attachment(row: Attachment) -> dict[str, Any]:
|
def _serialize_request_attachment(row: Attachment) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
|
|
@ -449,13 +460,14 @@ def _serialize_request_invoice(row: Invoice) -> dict[str, Any]:
|
||||||
|
|
||||||
|
|
||||||
def get_request_workspace_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
def get_request_workspace_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||||||
request_payload = get_request_service(request_id, db, admin)
|
|
||||||
request_uuid = request_uuid_or_400(request_id)
|
request_uuid = request_uuid_or_400(request_id)
|
||||||
req = db.get(Request, request_uuid)
|
req = db.get(Request, request_uuid)
|
||||||
if req is None:
|
if req is None:
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
ensure_lawyer_can_view_request_or_403(admin, req)
|
||||||
|
|
||||||
mark_messages_read_for_staff(db, request_id=req.id)
|
_apply_request_open_side_effects(db, req, admin, mark_chat_read=True)
|
||||||
|
request_payload = _serialize_request_row(req)
|
||||||
message_rows, messages_total, messages_has_more, messages_loaded_count = list_messages_for_request_window(
|
message_rows, messages_total, messages_has_more, messages_loaded_count = list_messages_for_request_window(
|
||||||
db,
|
db,
|
||||||
req.id,
|
req.id,
|
||||||
|
|
@ -501,7 +513,7 @@ def get_request_workspace_service(request_id: str, db: Session, admin: dict) ->
|
||||||
"paid_total": paid_total,
|
"paid_total": paid_total,
|
||||||
"last_paid_at": latest_paid_at.isoformat() if latest_paid_at else request_payload.get("paid_at"),
|
"last_paid_at": latest_paid_at.isoformat() if latest_paid_at else request_payload.get("paid_at"),
|
||||||
},
|
},
|
||||||
"status_route": get_request_status_route_service(request_id, db, admin),
|
"status_route": get_request_status_route_service(request_id, db, admin, request_row=req),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,10 @@ def get_request_status_route_service(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
admin: dict,
|
admin: dict,
|
||||||
|
request_row: Request | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
req = request_row
|
||||||
|
if req is None:
|
||||||
request_uuid = request_uuid_or_400(request_id)
|
request_uuid = request_uuid_or_400(request_id)
|
||||||
req = db.get(Request, request_uuid)
|
req = db.get(Request, request_uuid)
|
||||||
if not req:
|
if not req:
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ from app.services.s3_storage import build_object_key, get_s3_storage
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
AVATAR_MAX_SIZE_PX = 512
|
AVATAR_MAX_SIZE_PX = 512
|
||||||
|
AVATAR_THUMB_MAX_SIZE_PX = 160
|
||||||
AVATAR_WEBP_QUALITY = 80
|
AVATAR_WEBP_QUALITY = 80
|
||||||
_AVATAR_RESAMPLE = getattr(getattr(Image, "Resampling", Image), "LANCZOS", 1)
|
_AVATAR_RESAMPLE = getattr(getattr(Image, "Resampling", Image), "LANCZOS", 1)
|
||||||
|
|
||||||
|
|
@ -109,6 +110,10 @@ def _read_object_bytes_or_400(storage, key: str) -> bytes:
|
||||||
obj = storage.get_object(key)
|
obj = storage.get_object(key)
|
||||||
except ClientError:
|
except ClientError:
|
||||||
raise HTTPException(status_code=400, detail="Файл не найден в хранилище")
|
raise HTTPException(status_code=400, detail="Файл не найден в хранилище")
|
||||||
|
return _read_object_body_or_400(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_object_body_or_400(obj: dict) -> bytes:
|
||||||
body = obj.get("Body")
|
body = obj.get("Body")
|
||||||
if hasattr(body, "read"):
|
if hasattr(body, "read"):
|
||||||
data = body.read()
|
data = body.read()
|
||||||
|
|
@ -143,14 +148,27 @@ def _write_object_bytes_or_500(storage, *, key: str, content: bytes, mime_type:
|
||||||
raise HTTPException(status_code=500, detail="Хранилище не поддерживает запись объектов")
|
raise HTTPException(status_code=500, detail="Хранилище не поддерживает запись объектов")
|
||||||
|
|
||||||
|
|
||||||
def _normalize_avatar_to_webp_or_400(storage, *, key: str) -> tuple[int, str]:
|
def _avatar_variant_key(key: str, variant: str) -> str:
|
||||||
source = _read_object_bytes_or_400(storage, key)
|
raw = str(key or "").strip()
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный ключ аватара")
|
||||||
|
normalized_variant = str(variant or "").strip().lower()
|
||||||
|
if normalized_variant != "thumb":
|
||||||
|
raise HTTPException(status_code=400, detail="Неподдерживаемый вариант аватара")
|
||||||
|
prefix, _, file_name = raw.rpartition("/")
|
||||||
|
if not prefix or not file_name:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный ключ аватара")
|
||||||
|
base_name = file_name.rsplit(".", 1)[0] if "." in file_name else file_name
|
||||||
|
return prefix + "/" + base_name + "__thumb.webp"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_avatar_to_webp_or_400(source: bytes, *, max_size_px: int) -> bytes:
|
||||||
try:
|
try:
|
||||||
with Image.open(io.BytesIO(source)) as image:
|
with Image.open(io.BytesIO(source)) as image:
|
||||||
image = ImageOps.exif_transpose(image)
|
image = ImageOps.exif_transpose(image)
|
||||||
image.load()
|
image.load()
|
||||||
if max(image.size) > AVATAR_MAX_SIZE_PX:
|
if max(image.size) > max_size_px:
|
||||||
image.thumbnail((AVATAR_MAX_SIZE_PX, AVATAR_MAX_SIZE_PX), resample=_AVATAR_RESAMPLE)
|
image.thumbnail((max_size_px, max_size_px), resample=_AVATAR_RESAMPLE)
|
||||||
if image.mode != "RGB":
|
if image.mode != "RGB":
|
||||||
image = image.convert("RGB")
|
image = image.convert("RGB")
|
||||||
out = io.BytesIO()
|
out = io.BytesIO()
|
||||||
|
|
@ -162,8 +180,15 @@ def _normalize_avatar_to_webp_or_400(storage, *, key: str) -> tuple[int, str]:
|
||||||
raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара")
|
raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара")
|
||||||
if not optimized:
|
if not optimized:
|
||||||
raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара")
|
raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара")
|
||||||
_write_object_bytes_or_500(storage, key=key, content=optimized, mime_type="image/webp")
|
return optimized
|
||||||
return int(len(optimized)), "image/webp"
|
|
||||||
|
|
||||||
|
def _write_avatar_variant_or_400(storage, *, source_key: str, variant: str, max_size_px: int) -> tuple[str, int, str]:
|
||||||
|
source = _read_object_bytes_or_400(storage, source_key)
|
||||||
|
optimized = _render_avatar_to_webp_or_400(source, max_size_px=max_size_px)
|
||||||
|
target_key = _avatar_variant_key(source_key, variant)
|
||||||
|
_write_object_bytes_or_500(storage, key=target_key, content=optimized, mime_type="image/webp")
|
||||||
|
return target_key, int(len(optimized)), "image/webp"
|
||||||
|
|
||||||
|
|
||||||
def _serialize_attachment(row: Attachment) -> dict:
|
def _serialize_attachment(row: Attachment) -> dict:
|
||||||
|
|
@ -401,7 +426,12 @@ 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)
|
thumb_key, optimized_size, optimized_mime = _write_avatar_variant_or_400(
|
||||||
|
storage,
|
||||||
|
source_key=payload.key,
|
||||||
|
variant="thumb",
|
||||||
|
max_size_px=AVATAR_THUMB_MAX_SIZE_PX,
|
||||||
|
)
|
||||||
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)
|
||||||
|
|
@ -415,10 +445,12 @@ def upload_complete(
|
||||||
allowed=True,
|
allowed=True,
|
||||||
object_key=payload.key,
|
object_key=payload.key,
|
||||||
details={
|
details={
|
||||||
"mime_type": optimized_mime,
|
|
||||||
"size_bytes": int(optimized_size),
|
|
||||||
"source_mime_type": payload.mime_type,
|
"source_mime_type": payload.mime_type,
|
||||||
"source_size_bytes": int(actual_size),
|
"source_size_bytes": int(actual_size),
|
||||||
|
"variant": "thumb",
|
||||||
|
"variant_key": thumb_key,
|
||||||
|
"variant_mime_type": optimized_mime,
|
||||||
|
"variant_size_bytes": int(optimized_size),
|
||||||
},
|
},
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
|
|
@ -466,9 +498,11 @@ def get_object_proxy(
|
||||||
object_key: str,
|
object_key: str,
|
||||||
http_request: FastapiRequest,
|
http_request: FastapiRequest,
|
||||||
token: str = Query(...),
|
token: str = Query(...),
|
||||||
|
variant: str | None = Query(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
key = str(object_key or "").strip()
|
key = str(object_key or "").strip()
|
||||||
|
requested_variant = str(variant or "").strip().lower()
|
||||||
scope = "UNKNOWN"
|
scope = "UNKNOWN"
|
||||||
scoped_uuid: uuid.UUID | None = None
|
scoped_uuid: uuid.UUID | None = None
|
||||||
actor_role = "UNKNOWN"
|
actor_role = "UNKNOWN"
|
||||||
|
|
@ -521,8 +555,23 @@ def get_object_proxy(
|
||||||
raise HTTPException(status_code=404, detail="Файл не найден")
|
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||||
ensure_attachment_download_allowed_or_4xx(attachment)
|
ensure_attachment_download_allowed_or_4xx(attachment)
|
||||||
|
|
||||||
|
storage = get_s3_storage()
|
||||||
|
if scope == "avatars" and requested_variant == "thumb":
|
||||||
|
thumb_key = _avatar_variant_key(key, "thumb")
|
||||||
try:
|
try:
|
||||||
obj = get_s3_storage().get_object(key)
|
obj = storage.get_object(thumb_key)
|
||||||
|
except ClientError:
|
||||||
|
try:
|
||||||
|
source_obj = storage.get_object(key)
|
||||||
|
except ClientError:
|
||||||
|
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||||
|
source = _read_object_body_or_400(source_obj)
|
||||||
|
optimized = _render_avatar_to_webp_or_400(source, max_size_px=AVATAR_THUMB_MAX_SIZE_PX)
|
||||||
|
_write_object_bytes_or_500(storage, key=thumb_key, content=optimized, mime_type="image/webp")
|
||||||
|
obj = storage.get_object(thumb_key)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
obj = storage.get_object(key)
|
||||||
except ClientError:
|
except ClientError:
|
||||||
raise HTTPException(status_code=404, detail="Файл не найден")
|
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||||
|
|
||||||
|
|
@ -536,7 +585,7 @@ def get_object_proxy(
|
||||||
allowed=True,
|
allowed=True,
|
||||||
object_key=key,
|
object_key=key,
|
||||||
request_id=scoped_uuid if scope == "requests" else None,
|
request_id=scoped_uuid if scope == "requests" else None,
|
||||||
details={},
|
details={"variant": requested_variant or None},
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
persist_now=True,
|
persist_now=True,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,23 @@ from app.models.admin_user import AdminUser
|
||||||
from app.models.landing_featured_staff import LandingFeaturedStaff
|
from app.models.landing_featured_staff import LandingFeaturedStaff
|
||||||
from app.models.topic import Topic
|
from app.models.topic import Topic
|
||||||
from app.services.s3_storage import get_s3_storage
|
from app.services.s3_storage import get_s3_storage
|
||||||
|
from app.api.admin.uploads import (
|
||||||
|
AVATAR_THUMB_MAX_SIZE_PX,
|
||||||
|
_avatar_variant_key,
|
||||||
|
_read_object_body_or_400,
|
||||||
|
_render_avatar_to_webp_or_400,
|
||||||
|
_write_object_bytes_or_500,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _featured_avatar_proxy_path(admin_user_id: str) -> str:
|
def _featured_avatar_proxy_path(admin_user_id: str, variant: str | None = "thumb") -> str:
|
||||||
return "/api/public/featured-staff/avatar/" + str(admin_user_id)
|
path = "/api/public/featured-staff/avatar/" + str(admin_user_id)
|
||||||
|
normalized_variant = str(variant or "").strip().lower()
|
||||||
|
if normalized_variant:
|
||||||
|
return path + "?variant=" + normalized_variant
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
|
|
@ -58,7 +69,7 @@ def list_featured_staff(
|
||||||
raw_avatar_url = str(user.avatar_url or "").strip()
|
raw_avatar_url = str(user.avatar_url or "").strip()
|
||||||
avatar_url = raw_avatar_url
|
avatar_url = raw_avatar_url
|
||||||
if raw_avatar_url.startswith("s3://"):
|
if raw_avatar_url.startswith("s3://"):
|
||||||
avatar_url = _featured_avatar_proxy_path(str(user.id))
|
avatar_url = _featured_avatar_proxy_path(str(user.id), variant="thumb")
|
||||||
result.append(
|
result.append(
|
||||||
{
|
{
|
||||||
"id": str(slot.id),
|
"id": str(slot.id),
|
||||||
|
|
@ -80,6 +91,7 @@ def list_featured_staff(
|
||||||
@router.get("/avatar/{admin_user_id}")
|
@router.get("/avatar/{admin_user_id}")
|
||||||
def get_featured_staff_avatar(
|
def get_featured_staff_avatar(
|
||||||
admin_user_id: str,
|
admin_user_id: str,
|
||||||
|
variant: str | None = Query("thumb"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
|
@ -110,9 +122,30 @@ def get_featured_staff_avatar(
|
||||||
if not key.startswith("avatars/" + str(user_uuid) + "/"):
|
if not key.startswith("avatars/" + str(user_uuid) + "/"):
|
||||||
raise HTTPException(status_code=404, detail="Аватар не найден")
|
raise HTTPException(status_code=404, detail="Аватар не найден")
|
||||||
|
|
||||||
|
target_key = key
|
||||||
|
if str(variant or "").strip().lower() == "thumb":
|
||||||
try:
|
try:
|
||||||
obj = get_s3_storage().get_object(key)
|
target_key = _avatar_variant_key(key, "thumb")
|
||||||
|
except HTTPException:
|
||||||
|
target_key = key
|
||||||
|
|
||||||
|
storage = get_s3_storage()
|
||||||
|
try:
|
||||||
|
obj = storage.get_object(target_key)
|
||||||
except ClientError:
|
except ClientError:
|
||||||
|
if target_key != key:
|
||||||
|
try:
|
||||||
|
original_obj = storage.get_object(key)
|
||||||
|
except ClientError:
|
||||||
|
raise HTTPException(status_code=404, detail="Аватар не найден")
|
||||||
|
try:
|
||||||
|
source = _read_object_body_or_400(original_obj)
|
||||||
|
optimized = _render_avatar_to_webp_or_400(source, max_size_px=AVATAR_THUMB_MAX_SIZE_PX)
|
||||||
|
_write_object_bytes_or_500(storage, key=target_key, content=optimized, mime_type="image/webp")
|
||||||
|
obj = storage.get_object(target_key)
|
||||||
|
except HTTPException:
|
||||||
|
obj = original_obj
|
||||||
|
else:
|
||||||
raise HTTPException(status_code=404, detail="Аватар не найден")
|
raise HTTPException(status_code=404, detail="Аватар не найден")
|
||||||
|
|
||||||
body = obj.get("Body")
|
body = obj.get("Body")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import String, Integer, Boolean, DateTime
|
from sqlalchemy import String, Integer, Boolean, DateTime, Index
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
|
|
@ -10,6 +10,9 @@ from app.models.common import UUIDMixin, TimestampMixin
|
||||||
|
|
||||||
class Attachment(Base, UUIDMixin, TimestampMixin):
|
class Attachment(Base, UUIDMixin, TimestampMixin):
|
||||||
__tablename__ = "attachments"
|
__tablename__ = "attachments"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_attachments_request_created_id", "request_id", "created_at", "id"),
|
||||||
|
)
|
||||||
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||||||
message_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True, nullable=True)
|
message_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True, nullable=True)
|
||||||
file_name: Mapped[str] = mapped_column(String(300), nullable=False)
|
file_name: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Numeric, String, Text
|
from sqlalchemy import DateTime, Index, Numeric, String, Text
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
|
@ -11,6 +11,9 @@ from app.models.common import TimestampMixin, UUIDMixin
|
||||||
|
|
||||||
class Invoice(Base, UUIDMixin, TimestampMixin):
|
class Invoice(Base, UUIDMixin, TimestampMixin):
|
||||||
__tablename__ = "invoices"
|
__tablename__ = "invoices"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_invoices_request_issued_id", "request_id", "issued_at", "id"),
|
||||||
|
)
|
||||||
|
|
||||||
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||||||
client_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True, nullable=True)
|
client_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True, nullable=True)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import String, Boolean, DateTime
|
from sqlalchemy import String, Boolean, DateTime, Index
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
|
|
@ -10,6 +10,9 @@ from app.models.common import UUIDMixin, TimestampMixin
|
||||||
|
|
||||||
class Message(Base, UUIDMixin, TimestampMixin):
|
class Message(Base, UUIDMixin, TimestampMixin):
|
||||||
__tablename__ = "messages"
|
__tablename__ = "messages"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_messages_request_created_id", "request_id", "created_at", "id"),
|
||||||
|
)
|
||||||
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||||||
author_type: Mapped[str] = mapped_column(String(20), nullable=False) # CLIENT|LAWYER|SYSTEM
|
author_type: Mapped[str] = mapped_column(String(20), nullable=False) # CLIENT|LAWYER|SYSTEM
|
||||||
author_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
author_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ def _mark_counterparty_delivery(
|
||||||
request_id: Any,
|
request_id: Any,
|
||||||
recipient: str,
|
recipient: str,
|
||||||
mark_read: bool,
|
mark_read: bool,
|
||||||
|
commit: bool = True,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
side = str(recipient or "").strip().upper()
|
side = str(recipient or "").strip().upper()
|
||||||
if side not in {"CLIENT", "STAFF"}:
|
if side not in {"CLIENT", "STAFF"}:
|
||||||
|
|
@ -138,7 +139,7 @@ def _mark_counterparty_delivery(
|
||||||
if read_count:
|
if read_count:
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed and commit:
|
||||||
db.commit()
|
db.commit()
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
@ -155,8 +156,8 @@ def mark_messages_delivered_for_staff(db: Session, *, request_id: Any) -> bool:
|
||||||
return _mark_counterparty_delivery(db, request_id=request_id, recipient="STAFF", mark_read=False)
|
return _mark_counterparty_delivery(db, request_id=request_id, recipient="STAFF", mark_read=False)
|
||||||
|
|
||||||
|
|
||||||
def mark_messages_read_for_staff(db: Session, *, request_id: Any) -> bool:
|
def mark_messages_read_for_staff(db: Session, *, request_id: Any, commit: bool = True) -> bool:
|
||||||
return _mark_counterparty_delivery(db, request_id=request_id, recipient="STAFF", mark_read=True)
|
return _mark_counterparty_delivery(db, request_id=request_id, recipient="STAFF", mark_read=True, commit=commit)
|
||||||
|
|
||||||
|
|
||||||
def serialize_message(row: Message) -> dict[str, Any]:
|
def serialize_message(row: Message) -> dict[str, Any]:
|
||||||
|
|
|
||||||
|
|
@ -363,14 +363,19 @@ def mark_admin_notifications_read(
|
||||||
query = query.filter(Notification.request_id == request_id)
|
query = query.filter(Notification.request_id == request_id)
|
||||||
if notification_id is not None:
|
if notification_id is not None:
|
||||||
query = query.filter(Notification.id == notification_id)
|
query = query.filter(Notification.id == notification_id)
|
||||||
rows = query.all()
|
|
||||||
now = _as_utc_now()
|
now = _as_utc_now()
|
||||||
for row in rows:
|
return int(
|
||||||
row.is_read = True
|
query.update(
|
||||||
row.read_at = now
|
{
|
||||||
row.responsible = responsible
|
Notification.is_read: True,
|
||||||
db.add(row)
|
Notification.read_at: now,
|
||||||
return len(rows)
|
Notification.responsible: responsible,
|
||||||
|
Notification.updated_at: now,
|
||||||
|
},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def mark_client_notifications_read(
|
def mark_client_notifications_read(
|
||||||
|
|
@ -393,14 +398,19 @@ def mark_client_notifications_read(
|
||||||
query = query.filter(Notification.request_id == request_id)
|
query = query.filter(Notification.request_id == request_id)
|
||||||
if notification_id is not None:
|
if notification_id is not None:
|
||||||
query = query.filter(Notification.id == notification_id)
|
query = query.filter(Notification.id == notification_id)
|
||||||
rows = query.all()
|
|
||||||
now = _as_utc_now()
|
now = _as_utc_now()
|
||||||
for row in rows:
|
return int(
|
||||||
row.is_read = True
|
query.update(
|
||||||
row.read_at = now
|
{
|
||||||
row.responsible = responsible
|
Notification.is_read: True,
|
||||||
db.add(row)
|
Notification.read_at: now,
|
||||||
return len(rows)
|
Notification.responsible: responsible,
|
||||||
|
Notification.updated_at: now,
|
||||||
|
},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def list_admin_notifications(
|
def list_admin_notifications(
|
||||||
|
|
|
||||||
175
app/web/admin.js
175
app/web/admin.js
|
|
@ -2293,6 +2293,10 @@
|
||||||
currentImportantDateAt: "",
|
currentImportantDateAt: "",
|
||||||
pendingStatusChangePreset: null,
|
pendingStatusChangePreset: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
messagesHasMore: false,
|
||||||
|
messagesLoadingMore: false,
|
||||||
|
messagesLoadedCount: 0,
|
||||||
|
messagesTotal: 0,
|
||||||
attachments: [],
|
attachments: [],
|
||||||
messageDraft: "",
|
messageDraft: "",
|
||||||
selectedFiles: [],
|
selectedFiles: [],
|
||||||
|
|
@ -2505,13 +2509,14 @@
|
||||||
for (let i = 0; i < text.length; i += 1) hash = hash * 31 + text.charCodeAt(i) >>> 0;
|
for (let i = 0; i < text.length; i += 1) hash = hash * 31 + text.charCodeAt(i) >>> 0;
|
||||||
return palette[hash % palette.length];
|
return palette[hash % palette.length];
|
||||||
}
|
}
|
||||||
function resolveAvatarSrc(avatarUrl, accessToken) {
|
function resolveAvatarSrc(avatarUrl, accessToken, size) {
|
||||||
const raw = String(avatarUrl || "").trim();
|
const raw = String(avatarUrl || "").trim();
|
||||||
if (!raw) return "";
|
if (!raw) return "";
|
||||||
if (raw.startsWith("s3://")) {
|
if (raw.startsWith("s3://")) {
|
||||||
const key = raw.slice("s3://".length);
|
const key = raw.slice("s3://".length);
|
||||||
if (!key || !accessToken) return "";
|
if (!key || !accessToken) return "";
|
||||||
return "/api/admin/uploads/object/" + encodeURIComponent(key) + "?token=" + encodeURIComponent(accessToken);
|
const useThumb = Number(size || 0) > 0 && Number(size || 0) <= 160;
|
||||||
|
return "/api/admin/uploads/object/" + encodeURIComponent(key) + "?token=" + encodeURIComponent(accessToken) + (useThumb ? "&variant=thumb" : "");
|
||||||
}
|
}
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
@ -3694,6 +3699,8 @@
|
||||||
currentImportantDateAt,
|
currentImportantDateAt,
|
||||||
pendingStatusChangePreset,
|
pendingStatusChangePreset,
|
||||||
messages,
|
messages,
|
||||||
|
messagesHasMore,
|
||||||
|
messagesLoadingMore,
|
||||||
attachments,
|
attachments,
|
||||||
messageDraft,
|
messageDraft,
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
|
|
@ -3701,6 +3708,7 @@
|
||||||
status,
|
status,
|
||||||
onMessageChange,
|
onMessageChange,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
|
onLoadOlderMessages,
|
||||||
onFilesSelect,
|
onFilesSelect,
|
||||||
onRemoveSelectedFile,
|
onRemoveSelectedFile,
|
||||||
onClearSelectedFiles,
|
onClearSelectedFiles,
|
||||||
|
|
@ -5066,7 +5074,16 @@
|
||||||
disabled: loading || fileUploading,
|
disabled: loading || fileUploading,
|
||||||
style: { position: "absolute", width: "1px", height: "1px", opacity: 0, pointerEvents: "none" }
|
style: { position: "absolute", width: "1px", height: "1px", opacity: 0, pointerEvents: "none" }
|
||||||
}
|
}
|
||||||
), chatTab === "chat" ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("ul", { className: "simple-list request-modal-list request-chat-list", id: idMap.messagesList, ref: chatListRef }, chatTimelineItems.length ? chatTimelineItems.map(
|
), chatTab === "chat" ? /* @__PURE__ */ React.createElement(React.Fragment, null, messagesHasMore ? /* @__PURE__ */ React.createElement("div", { className: "request-chat-history-actions" }, /* @__PURE__ */ React.createElement(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
className: "btn secondary",
|
||||||
|
onClick: onLoadOlderMessages,
|
||||||
|
disabled: loading || fileUploading || messagesLoadingMore
|
||||||
|
},
|
||||||
|
messagesLoadingMore ? "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u0438\u0441\u0442\u043E\u0440\u0438\u0438..." : "\u041F\u043E\u043A\u0430\u0437\u0430\u0442\u044C \u043F\u0440\u0435\u0434\u044B\u0434\u0443\u0449\u0438\u0435 \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F"
|
||||||
|
)) : null, /* @__PURE__ */ React.createElement("ul", { className: "simple-list request-modal-list request-chat-list", id: idMap.messagesList, ref: chatListRef }, chatTimelineItems.length ? chatTimelineItems.map(
|
||||||
(entry) => entry.type === "date" ? /* @__PURE__ */ React.createElement("li", { key: entry.key, className: "chat-date-divider" }, /* @__PURE__ */ React.createElement("span", null, entry.label)) : entry.type === "file" ? /* @__PURE__ */ React.createElement(
|
(entry) => entry.type === "date" ? /* @__PURE__ */ React.createElement("li", { key: entry.key, className: "chat-date-divider" }, /* @__PURE__ */ React.createElement("span", null, entry.label)) : entry.type === "file" ? /* @__PURE__ */ React.createElement(
|
||||||
"li",
|
"li",
|
||||||
{
|
{
|
||||||
|
|
@ -6250,7 +6267,11 @@
|
||||||
requestData: null,
|
requestData: null,
|
||||||
financeSummary: null,
|
financeSummary: null,
|
||||||
invoices: [],
|
invoices: [],
|
||||||
statusRouteNodes: []
|
statusRouteNodes: [],
|
||||||
|
messagesHasMore: false,
|
||||||
|
messagesLoadingMore: false,
|
||||||
|
messagesLoadedCount: 0,
|
||||||
|
messagesTotal: 0
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -6312,6 +6333,10 @@
|
||||||
availableStatuses: Array.isArray(statusRouteData?.available_statuses) ? statusRouteData.available_statuses : [],
|
availableStatuses: Array.isArray(statusRouteData?.available_statuses) ? statusRouteData.available_statuses : [],
|
||||||
currentImportantDateAt: String(statusRouteData?.current_important_date_at || rowData?.important_date_at || ""),
|
currentImportantDateAt: String(statusRouteData?.current_important_date_at || rowData?.important_date_at || ""),
|
||||||
messages: normalizedMessages,
|
messages: normalizedMessages,
|
||||||
|
messagesHasMore: Boolean(workspaceData?.messages_has_more),
|
||||||
|
messagesLoadingMore: false,
|
||||||
|
messagesLoadedCount: Number(workspaceData?.messages_loaded_count || normalizedMessages.length || 0),
|
||||||
|
messagesTotal: Number(workspaceData?.messages_total || normalizedMessages.length || 0),
|
||||||
attachments,
|
attachments,
|
||||||
selectedFiles: [],
|
selectedFiles: [],
|
||||||
fileUploading: false
|
fileUploading: false
|
||||||
|
|
@ -6330,6 +6355,10 @@
|
||||||
availableStatuses: [],
|
availableStatuses: [],
|
||||||
currentImportantDateAt: "",
|
currentImportantDateAt: "",
|
||||||
messages: [],
|
messages: [],
|
||||||
|
messagesHasMore: false,
|
||||||
|
messagesLoadingMore: false,
|
||||||
|
messagesLoadedCount: 0,
|
||||||
|
messagesTotal: 0,
|
||||||
attachments: [],
|
attachments: [],
|
||||||
selectedFiles: [],
|
selectedFiles: [],
|
||||||
fileUploading: false
|
fileUploading: false
|
||||||
|
|
@ -6478,17 +6507,57 @@
|
||||||
download_url: resolveAdminObjectSrc2(item?.s3_key, token)
|
download_url: resolveAdminObjectSrc2(item?.s3_key, token)
|
||||||
}));
|
}));
|
||||||
if (nextMessages.length || nextAttachments.length) {
|
if (nextMessages.length || nextAttachments.length) {
|
||||||
setRequestModal((prev) => ({
|
setRequestModal((prev) => {
|
||||||
|
const mergedMessages = mergeRowsById(prev.messages, nextMessages);
|
||||||
|
const previousCount = Array.isArray(prev.messages) ? prev.messages.length : 0;
|
||||||
|
const addedCount = Math.max(0, mergedMessages.length - previousCount);
|
||||||
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
messages: mergeRowsById(prev.messages, nextMessages),
|
messages: mergedMessages,
|
||||||
|
messagesLoadedCount: Number(prev.messagesLoadedCount || previousCount) + addedCount,
|
||||||
|
messagesTotal: Number(prev.messagesTotal || previousCount) + addedCount,
|
||||||
attachments: mergeRowsById(prev.attachments, nextAttachments)
|
attachments: mergeRowsById(prev.attachments, nextAttachments)
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return payload || { has_updates: false, typing: [], cursor: null };
|
return payload || { has_updates: false, typing: [], cursor: null };
|
||||||
},
|
},
|
||||||
[api, requestModal.requestId, resolveAdminObjectSrc2, token, users]
|
[api, requestModal.requestId, resolveAdminObjectSrc2, token, users]
|
||||||
);
|
);
|
||||||
|
const loadOlderRequestMessages = useCallback(async () => {
|
||||||
|
const requestId = String(requestModal.requestId || "").trim();
|
||||||
|
const loadedCount = Number(requestModal.messagesLoadedCount || 0);
|
||||||
|
if (!api || !requestId || requestModal.messagesLoadingMore || !requestModal.messagesHasMore) return null;
|
||||||
|
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: true }));
|
||||||
|
try {
|
||||||
|
const payload = await api(
|
||||||
|
"/api/admin/chat/requests/" + requestId + "/messages-window?before_count=" + encodeURIComponent(String(loadedCount))
|
||||||
|
);
|
||||||
|
const nextMessages = normalizeMessageAuthors(payload?.rows || [], users);
|
||||||
|
setRequestModal((prev) => ({
|
||||||
|
...prev,
|
||||||
|
messagesLoadingMore: false,
|
||||||
|
messages: mergeRowsById(nextMessages, prev.messages),
|
||||||
|
messagesHasMore: Boolean(payload?.has_more),
|
||||||
|
messagesLoadedCount: Number(payload?.loaded_count || prev.messagesLoadedCount || 0),
|
||||||
|
messagesTotal: Number(payload?.total || prev.messagesTotal || 0)
|
||||||
|
}));
|
||||||
|
return payload || null;
|
||||||
|
} catch (error) {
|
||||||
|
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: false }));
|
||||||
|
if (typeof setStatus === "function") setStatus("requestModal", "\u041E\u0448\u0438\u0431\u043A\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0438 \u0438\u0441\u0442\u043E\u0440\u0438\u0438: " + error.message, "error");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
requestModal.messagesHasMore,
|
||||||
|
requestModal.messagesLoadedCount,
|
||||||
|
requestModal.messagesLoadingMore,
|
||||||
|
requestModal.requestId,
|
||||||
|
setStatus,
|
||||||
|
users
|
||||||
|
]);
|
||||||
const setRequestTyping = useCallback(
|
const setRequestTyping = useCallback(
|
||||||
async ({ typing } = {}) => {
|
async ({ typing } = {}) => {
|
||||||
const requestId = requestModal.requestId;
|
const requestId = requestModal.requestId;
|
||||||
|
|
@ -6604,6 +6673,7 @@
|
||||||
submitRequestStatusChange,
|
submitRequestStatusChange,
|
||||||
submitRequestModalMessage,
|
submitRequestModalMessage,
|
||||||
probeRequestLive,
|
probeRequestLive,
|
||||||
|
loadOlderRequestMessages,
|
||||||
setRequestTyping,
|
setRequestTyping,
|
||||||
loadRequestDataTemplates,
|
loadRequestDataTemplates,
|
||||||
loadRequestDataBatch,
|
loadRequestDataBatch,
|
||||||
|
|
@ -7074,9 +7144,19 @@
|
||||||
useEffect(() => setBroken(false), [avatarUrl]);
|
useEffect(() => setBroken(false), [avatarUrl]);
|
||||||
const initials = userInitials(name, email);
|
const initials = userInitials(name, email);
|
||||||
const bg = avatarColor(name || email || initials);
|
const bg = avatarColor(name || email || initials);
|
||||||
const src = resolveAvatarSrc(avatarUrl, accessToken);
|
const src = resolveAvatarSrc(avatarUrl, accessToken, size);
|
||||||
const canShowImage = Boolean(src && !broken);
|
const canShowImage = Boolean(src && !broken);
|
||||||
return /* @__PURE__ */ React.createElement("span", { className: "avatar", style: { width: size + "px", height: size + "px", backgroundColor: bg } }, canShowImage ? /* @__PURE__ */ React.createElement("img", { src, alt: name || email || "avatar", onError: () => setBroken(true) }) : /* @__PURE__ */ React.createElement("span", null, initials));
|
return /* @__PURE__ */ React.createElement("span", { className: "avatar", style: { width: size + "px", height: size + "px", backgroundColor: bg } }, canShowImage ? /* @__PURE__ */ React.createElement(
|
||||||
|
"img",
|
||||||
|
{
|
||||||
|
src,
|
||||||
|
alt: name || email || "avatar",
|
||||||
|
loading: "lazy",
|
||||||
|
decoding: "async",
|
||||||
|
fetchPriority: size >= 64 ? "low" : "auto",
|
||||||
|
onError: () => setBroken(true)
|
||||||
|
}
|
||||||
|
) : /* @__PURE__ */ React.createElement("span", null, initials));
|
||||||
}
|
}
|
||||||
function LoginScreen({ onSubmit, status }) {
|
function LoginScreen({ onSubmit, status }) {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
|
@ -7505,6 +7585,7 @@
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [userId, setUserId] = useState("");
|
const [userId, setUserId] = useState("");
|
||||||
const [activeSection, setActiveSection] = useState(initialSection);
|
const [activeSection, setActiveSection] = useState(initialSection);
|
||||||
|
const dashboardLoadRef = useRef(0);
|
||||||
const [dashboardData, setDashboardData] = useState({
|
const [dashboardData, setDashboardData] = useState({
|
||||||
scope: "",
|
scope: "",
|
||||||
cards: [],
|
cards: [],
|
||||||
|
|
@ -7625,6 +7706,7 @@
|
||||||
submitRequestStatusChange,
|
submitRequestStatusChange,
|
||||||
submitRequestModalMessage,
|
submitRequestModalMessage,
|
||||||
probeRequestLive,
|
probeRequestLive,
|
||||||
|
loadOlderRequestMessages,
|
||||||
setRequestTyping,
|
setRequestTyping,
|
||||||
loadRequestDataTemplates,
|
loadRequestDataTemplates,
|
||||||
loadRequestDataBatch,
|
loadRequestDataBatch,
|
||||||
|
|
@ -8412,34 +8494,36 @@
|
||||||
}, [configActiveKey, dictionaries.topics, loadTable, statusDesignerTopicCode]);
|
}, [configActiveKey, dictionaries.topics, loadTable, statusDesignerTopicCode]);
|
||||||
const loadDashboard = useCallback(
|
const loadDashboard = useCallback(
|
||||||
async (tokenOverride) => {
|
async (tokenOverride) => {
|
||||||
|
const loadId = Date.now();
|
||||||
|
dashboardLoadRef.current = loadId;
|
||||||
setStatus("dashboard", "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...", "");
|
setStatus("dashboard", "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...", "");
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/admin/metrics/overview", {}, tokenOverride);
|
const buildDashboardCards = (scope2, payload) => scope2 === "LAWYER" ? [
|
||||||
const scope = String(data.scope || role || "");
|
{ label: "\u041C\u043E\u0438 \u0437\u0430\u044F\u0432\u043A\u0438", value: payload.assigned_total ?? 0 },
|
||||||
const cards = scope === "LAWYER" ? [
|
{ label: "\u041C\u043E\u0438 \u0430\u043A\u0442\u0438\u0432\u043D\u044B\u0435", value: payload.active_assigned_total ?? 0 },
|
||||||
{ label: "\u041C\u043E\u0438 \u0437\u0430\u044F\u0432\u043A\u0438", value: data.assigned_total ?? 0 },
|
{ label: "\u041D\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0435", value: payload.unassigned_total ?? 0 },
|
||||||
{ label: "\u041C\u043E\u0438 \u0430\u043A\u0442\u0438\u0432\u043D\u044B\u0435", value: data.active_assigned_total ?? 0 },
|
{ label: "\u041C\u043E\u0438 \u043D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043D\u044B\u0435", value: payload.my_unread_notifications_total ?? payload.my_unread_updates ?? 0 },
|
||||||
{ label: "\u041D\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0435", value: data.unassigned_total ?? 0 },
|
{ label: "\u041F\u0440\u043E\u0441\u0440\u043E\u0447\u0435\u043D\u043E SLA", value: payload.sla_overdue ?? 0 }
|
||||||
{ label: "\u041C\u043E\u0438 \u043D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043D\u044B\u0435", value: data.my_unread_notifications_total ?? data.my_unread_updates ?? 0 },
|
|
||||||
{ label: "\u041F\u0440\u043E\u0441\u0440\u043E\u0447\u0435\u043D\u043E SLA", value: data.sla_overdue ?? 0 }
|
|
||||||
] : [
|
] : [
|
||||||
{ label: "\u041D\u043E\u0432\u044B\u0435", value: data.new ?? 0 },
|
{ label: "\u041D\u043E\u0432\u044B\u0435", value: payload.new ?? 0 },
|
||||||
{ label: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0435", value: data.assigned_total ?? 0 },
|
{ label: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0435", value: payload.assigned_total ?? 0 },
|
||||||
{ label: "\u041D\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0435", value: data.unassigned_total ?? 0 },
|
{ label: "\u041D\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0435", value: payload.unassigned_total ?? 0 },
|
||||||
{ label: "\u041F\u0440\u043E\u0441\u0440\u043E\u0447\u0435\u043D\u043E SLA", value: data.sla_overdue ?? 0 },
|
{ label: "\u041F\u0440\u043E\u0441\u0440\u043E\u0447\u0435\u043D\u043E SLA", value: payload.sla_overdue ?? 0 },
|
||||||
{ label: "\u041C\u043E\u0438 \u043D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043D\u044B\u0435", value: data.my_unread_notifications_total ?? data.my_unread_updates ?? 0 },
|
{ label: "\u041C\u043E\u0438 \u043D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043D\u044B\u0435", value: payload.my_unread_notifications_total ?? payload.my_unread_updates ?? 0 },
|
||||||
{ label: "\u0412\u044B\u0440\u0443\u0447\u043A\u0430 (\u043C\u0435\u0441.)", value: Number(data.month_revenue ?? 0).toFixed(2) },
|
{ label: "\u0412\u044B\u0440\u0443\u0447\u043A\u0430 (\u043C\u0435\u0441.)", value: Number(payload.month_revenue ?? 0).toFixed(2) },
|
||||||
{ label: "\u0420\u0430\u0441\u0445\u043E\u0434\u044B (\u043C\u0435\u0441.)", value: Number(data.month_expenses ?? 0).toFixed(2) },
|
{ label: "\u0420\u0430\u0441\u0445\u043E\u0434\u044B (\u043C\u0435\u0441.)", value: Number(payload.month_expenses ?? 0).toFixed(2) },
|
||||||
{ label: "\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u044E\u0440\u0438\u0441\u0442\u0430\u043C\u0438", value: data.unread_for_lawyers ?? 0 },
|
{ label: "\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u044E\u0440\u0438\u0441\u0442\u0430\u043C\u0438", value: payload.unread_for_lawyers ?? 0 },
|
||||||
{ label: "\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u043A\u043B\u0438\u0435\u043D\u0442\u0430\u043C\u0438", value: data.unread_for_clients ?? 0 }
|
{ label: "\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u043A\u043B\u0438\u0435\u043D\u0442\u0430\u043C\u0438", value: payload.unread_for_clients ?? 0 }
|
||||||
];
|
];
|
||||||
|
const data = await api("/api/admin/metrics/overview?include_sla=false", {}, tokenOverride);
|
||||||
|
const scope = String(data.scope || role || "");
|
||||||
const localized = {};
|
const localized = {};
|
||||||
Object.entries(data.by_status || {}).forEach(([code, count]) => {
|
Object.entries(data.by_status || {}).forEach(([code, count]) => {
|
||||||
localized[statusLabel(code)] = count;
|
localized[statusLabel(code)] = count;
|
||||||
});
|
});
|
||||||
setDashboardData({
|
setDashboardData({
|
||||||
scope,
|
scope,
|
||||||
cards,
|
cards: buildDashboardCards(scope, data),
|
||||||
byStatus: localized,
|
byStatus: localized,
|
||||||
lawyerLoads: data.lawyer_loads || [],
|
lawyerLoads: data.lawyer_loads || [],
|
||||||
myUnreadByEvent: data.my_unread_by_event || {},
|
myUnreadByEvent: data.my_unread_by_event || {},
|
||||||
|
|
@ -8453,6 +8537,17 @@
|
||||||
monthExpenses: Number(data.month_expenses || 0)
|
monthExpenses: Number(data.month_expenses || 0)
|
||||||
});
|
});
|
||||||
setStatus("dashboard", "\u0414\u0430\u043D\u043D\u044B\u0435 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u044B", "ok");
|
setStatus("dashboard", "\u0414\u0430\u043D\u043D\u044B\u0435 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u044B", "ok");
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const slaData = await api("/api/admin/metrics/overview-sla", {}, tokenOverride);
|
||||||
|
if (dashboardLoadRef.current !== loadId) return;
|
||||||
|
setDashboardData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
cards: buildDashboardCards(String(prev.scope || scope || ""), { ...data, ...slaData })
|
||||||
|
}));
|
||||||
|
} catch (_) {
|
||||||
|
}
|
||||||
|
})();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("dashboard", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error");
|
setStatus("dashboard", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error");
|
||||||
}
|
}
|
||||||
|
|
@ -9670,13 +9765,34 @@
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !role) return;
|
if (!token || !role) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let deferredBootstrapCleanup = null;
|
||||||
|
const scheduleDeferredBootstrap = () => {
|
||||||
|
if (typeof window !== "undefined" && typeof window.requestIdleCallback === "function") {
|
||||||
|
const handle2 = window.requestIdleCallback(() => {
|
||||||
|
if (!cancelled) bootstrapReferenceData(token, role);
|
||||||
|
}, { timeout: 1500 });
|
||||||
|
return () => {
|
||||||
|
if (typeof window.cancelIdleCallback === "function") window.cancelIdleCallback(handle2);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const handle = window.setTimeout(() => {
|
||||||
|
if (!cancelled) bootstrapReferenceData(token, role);
|
||||||
|
}, 250);
|
||||||
|
return () => window.clearTimeout(handle);
|
||||||
|
};
|
||||||
(async () => {
|
(async () => {
|
||||||
|
if (!isRequestWorkspaceRoute && !routeInfo.section) {
|
||||||
|
if (!cancelled) await loadDashboard(token);
|
||||||
|
if (!cancelled) await loadTotpStatus(token);
|
||||||
|
if (!cancelled) deferredBootstrapCleanup = scheduleDeferredBootstrap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
bootstrapReferenceData(token, role);
|
bootstrapReferenceData(token, role);
|
||||||
if (!cancelled && !isRequestWorkspaceRoute && !routeInfo.section) await loadDashboard(token);
|
|
||||||
if (!cancelled) await loadTotpStatus(token);
|
if (!cancelled) await loadTotpStatus(token);
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (typeof deferredBootstrapCleanup === "function") deferredBootstrapCleanup();
|
||||||
};
|
};
|
||||||
}, [bootstrapReferenceData, isRequestWorkspaceRoute, loadDashboard, loadTotpStatus, role, routeInfo.section, token]);
|
}, [bootstrapReferenceData, isRequestWorkspaceRoute, loadDashboard, loadTotpStatus, role, routeInfo.section, token]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -10033,6 +10149,8 @@
|
||||||
currentImportantDateAt: requestModal.currentImportantDateAt || "",
|
currentImportantDateAt: requestModal.currentImportantDateAt || "",
|
||||||
pendingStatusChangePreset: requestModal.pendingStatusChangePreset,
|
pendingStatusChangePreset: requestModal.pendingStatusChangePreset,
|
||||||
messages: requestModal.messages || [],
|
messages: requestModal.messages || [],
|
||||||
|
messagesHasMore: Boolean(requestModal.messagesHasMore),
|
||||||
|
messagesLoadingMore: Boolean(requestModal.messagesLoadingMore),
|
||||||
attachments: requestModal.attachments || [],
|
attachments: requestModal.attachments || [],
|
||||||
messageDraft: requestModal.messageDraft || "",
|
messageDraft: requestModal.messageDraft || "",
|
||||||
selectedFiles: requestModal.selectedFiles || [],
|
selectedFiles: requestModal.selectedFiles || [],
|
||||||
|
|
@ -10040,6 +10158,7 @@
|
||||||
status: getStatus("requestModal"),
|
status: getStatus("requestModal"),
|
||||||
onMessageChange: updateRequestModalMessageDraft,
|
onMessageChange: updateRequestModalMessageDraft,
|
||||||
onSendMessage: submitRequestModalMessage,
|
onSendMessage: submitRequestModalMessage,
|
||||||
|
onLoadOlderMessages: loadOlderRequestMessages,
|
||||||
onFilesSelect: appendRequestModalFiles,
|
onFilesSelect: appendRequestModalFiles,
|
||||||
onRemoveSelectedFile: removeRequestModalFile,
|
onRemoveSelectedFile: removeRequestModalFile,
|
||||||
onClearSelectedFiles: clearRequestModalFiles,
|
onClearSelectedFiles: clearRequestModalFiles,
|
||||||
|
|
|
||||||
|
|
@ -274,12 +274,19 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
useEffect(() => setBroken(false), [avatarUrl]);
|
useEffect(() => setBroken(false), [avatarUrl]);
|
||||||
const initials = userInitials(name, email);
|
const initials = userInitials(name, email);
|
||||||
const bg = avatarColor(name || email || initials);
|
const bg = avatarColor(name || email || initials);
|
||||||
const src = resolveAvatarSrc(avatarUrl, accessToken);
|
const src = resolveAvatarSrc(avatarUrl, accessToken, size);
|
||||||
const canShowImage = Boolean(src && !broken);
|
const canShowImage = Boolean(src && !broken);
|
||||||
return (
|
return (
|
||||||
<span className="avatar" style={{ width: size + "px", height: size + "px", backgroundColor: bg }}>
|
<span className="avatar" style={{ width: size + "px", height: size + "px", backgroundColor: bg }}>
|
||||||
{canShowImage ? (
|
{canShowImage ? (
|
||||||
<img src={src} alt={name || email || "avatar"} onError={() => setBroken(true)} />
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={name || email || "avatar"}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
fetchPriority={size >= 64 ? "low" : "auto"}
|
||||||
|
onError={() => setBroken(true)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span>{initials}</span>
|
<span>{initials}</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -3534,13 +3541,34 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !role) return;
|
if (!token || !role) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let deferredBootstrapCleanup = null;
|
||||||
|
const scheduleDeferredBootstrap = () => {
|
||||||
|
if (typeof window !== "undefined" && typeof window.requestIdleCallback === "function") {
|
||||||
|
const handle = window.requestIdleCallback(() => {
|
||||||
|
if (!cancelled) bootstrapReferenceData(token, role);
|
||||||
|
}, { timeout: 1500 });
|
||||||
|
return () => {
|
||||||
|
if (typeof window.cancelIdleCallback === "function") window.cancelIdleCallback(handle);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const handle = window.setTimeout(() => {
|
||||||
|
if (!cancelled) bootstrapReferenceData(token, role);
|
||||||
|
}, 250);
|
||||||
|
return () => window.clearTimeout(handle);
|
||||||
|
};
|
||||||
(async () => {
|
(async () => {
|
||||||
|
if (!isRequestWorkspaceRoute && !routeInfo.section) {
|
||||||
|
if (!cancelled) await loadDashboard(token);
|
||||||
|
if (!cancelled) await loadTotpStatus(token);
|
||||||
|
if (!cancelled) deferredBootstrapCleanup = scheduleDeferredBootstrap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
bootstrapReferenceData(token, role);
|
bootstrapReferenceData(token, role);
|
||||||
if (!cancelled && !isRequestWorkspaceRoute && !routeInfo.section) await loadDashboard(token);
|
|
||||||
if (!cancelled) await loadTotpStatus(token);
|
if (!cancelled) await loadTotpStatus(token);
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (typeof deferredBootstrapCleanup === "function") deferredBootstrapCleanup();
|
||||||
};
|
};
|
||||||
}, [bootstrapReferenceData, isRequestWorkspaceRoute, loadDashboard, loadTotpStatus, role, routeInfo.section, token]);
|
}, [bootstrapReferenceData, isRequestWorkspaceRoute, loadDashboard, loadTotpStatus, role, routeInfo.section, token]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -225,13 +225,20 @@ export function avatarColor(seed) {
|
||||||
return palette[hash % palette.length];
|
return palette[hash % palette.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAvatarSrc(avatarUrl, accessToken) {
|
export function resolveAvatarSrc(avatarUrl, accessToken, size) {
|
||||||
const raw = String(avatarUrl || "").trim();
|
const raw = String(avatarUrl || "").trim();
|
||||||
if (!raw) return "";
|
if (!raw) return "";
|
||||||
if (raw.startsWith("s3://")) {
|
if (raw.startsWith("s3://")) {
|
||||||
const key = raw.slice("s3://".length);
|
const key = raw.slice("s3://".length);
|
||||||
if (!key || !accessToken) return "";
|
if (!key || !accessToken) return "";
|
||||||
return "/api/admin/uploads/object/" + encodeURIComponent(key) + "?token=" + encodeURIComponent(accessToken);
|
const useThumb = Number(size || 0) > 0 && Number(size || 0) <= 160;
|
||||||
|
return (
|
||||||
|
"/api/admin/uploads/object/" +
|
||||||
|
encodeURIComponent(key) +
|
||||||
|
"?token=" +
|
||||||
|
encodeURIComponent(accessToken) +
|
||||||
|
(useThumb ? "&variant=thumb" : "")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,14 @@
|
||||||
- 2026-03-16: при первом прогоне long-chat сценария выяснилось, что `chat-service` работал на старом контейнере без актуальных `X-Perf-*` headers; после rebuild `chat-service` server timing для `messages-window` подтвержден на живом контуре.
|
- 2026-03-16: при первом прогоне long-chat сценария выяснилось, что `chat-service` работал на старом контейнере без актуальных `X-Perf-*` headers; после rebuild `chat-service` server timing для `messages-window` подтвержден на живом контуре.
|
||||||
- 2026-03-16: `PERF-06` продвинут дальше - SQL-first window теперь покрывает не только `created_newest`, но и `sort_mode=lawyer`, а boolean-фильтр `deadline_alert` переносится в SQL до загрузки строк.
|
- 2026-03-16: `PERF-06` продвинут дальше - SQL-first window теперь покрывает не только `created_newest`, но и `sort_mode=lawyer`, а boolean-фильтр `deadline_alert` переносится в SQL до загрузки строк.
|
||||||
- 2026-03-16: добавлен контейнерный регресс `test_requests_kanban_lawyer_sort_uses_limit_without_losing_total`, подтверждающий `limit/truncated/total` для `sort_mode=lawyer`.
|
- 2026-03-16: добавлен контейнерный регресс `test_requests_kanban_lawyer_sort_uses_limit_without_losing_total`, подтверждающий `limit/truncated/total` для `sort_mode=lawyer`.
|
||||||
|
- 2026-03-17: по продовым замерам dashboard все еще создает сетевое давление пачкой справочников на первом маунте; admin UI перестроен так, чтобы на дефолтном входе сначала грузить `dashboard + totp`, а `bootstrapReferenceData()` откладывать до idle.
|
||||||
|
- 2026-03-17: `UserAvatar` переведен на `loading="lazy"`, `decoding="async"` и low fetch priority для крупных аватаров, чтобы тяжелые изображения юристов меньше конкурировали с API на первом экране dashboard.
|
||||||
|
- 2026-03-17: для `workspace` добавлены составные индексы `messages(request_id, created_at, id)`, `attachments(request_id, created_at, id)`, `invoices(request_id, issued_at, id)`; это должно снизить стоимость order-by выборок при открытии заявки.
|
||||||
|
- 2026-03-17: добавлена миграция `0035_workspace_perf_indexes`, обновлен `tests.test_migrations`, контейнерный прогон миграций пройден.
|
||||||
|
- 2026-03-17: avatar pipeline исправлен архитектурно: оригинал аватара больше не переписывается, рядом создается `thumb.webp`, а admin avatar URLs для small/medium render идут через `variant=thumb`.
|
||||||
|
- 2026-03-17: admin avatar proxy умеет по `variant=thumb` отдавать сжатый вариант и, если его еще нет, достраивать его на лету из оригинала; public featured staff URLs тоже переключены на `?variant=thumb` и умеют так же достраивать thumb на лету.
|
||||||
|
- 2026-03-17: `workspace` упрощен server-side: убрано дублирующее `get_request_service() + db.get(Request)` внутри одного запроса, read-mark side effects сведены в один проход, `mark_admin_notifications_read` переведен на bulk update, `status_route` повторно использует уже загруженный `Request`.
|
||||||
|
- 2026-03-17: контейнерные регрессы после avatar/workspace правок пройдены: `tests.test_uploads_s3`, `tests.test_featured_staff_public`, `tests.admin.test_lawyer_chat`.
|
||||||
|
|
||||||
## Дальше
|
## Дальше
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ class FeaturedStaffPublicTests(unittest.TestCase):
|
||||||
self.assertEqual(len(payload.get("items") or []), 1)
|
self.assertEqual(len(payload.get("items") or []), 1)
|
||||||
row = payload["items"][0]
|
row = payload["items"][0]
|
||||||
self.assertEqual(row.get("admin_user_id"), user_id)
|
self.assertEqual(row.get("admin_user_id"), user_id)
|
||||||
self.assertEqual(row.get("avatar_url"), "/api/public/featured-staff/avatar/" + user_id)
|
self.assertEqual(row.get("avatar_url"), "/api/public/featured-staff/avatar/" + user_id + "?variant=thumb")
|
||||||
|
|
||||||
def test_featured_staff_avatar_proxy_streams_s3_avatar(self):
|
def test_featured_staff_avatar_proxy_streams_s3_avatar(self):
|
||||||
user_id, avatar_key = self._seed_featured_lawyer(enabled=True)
|
user_id, avatar_key = self._seed_featured_lawyer(enabled=True)
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ class MigrationTests(unittest.TestCase):
|
||||||
def test_alembic_version_is_set(self):
|
def test_alembic_version_is_set(self):
|
||||||
with self.engine.connect() as conn:
|
with self.engine.connect() as conn:
|
||||||
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
||||||
self.assertEqual(version, "0034_request_assigned_lawyer_idx")
|
self.assertEqual(version, "0035_workspace_perf_indexes")
|
||||||
|
|
||||||
def test_responsible_column_exists_in_all_domain_tables(self):
|
def test_responsible_column_exists_in_all_domain_tables(self):
|
||||||
tables = {
|
tables = {
|
||||||
|
|
@ -209,6 +209,14 @@ class MigrationTests(unittest.TestCase):
|
||||||
indexes = {index["name"] for index in self.inspector.get_indexes("requests")}
|
indexes = {index["name"] for index in self.inspector.get_indexes("requests")}
|
||||||
self.assertIn("ix_requests_assigned_lawyer_id", indexes)
|
self.assertIn("ix_requests_assigned_lawyer_id", indexes)
|
||||||
|
|
||||||
|
def test_workspace_payload_tables_contain_ordering_indexes(self):
|
||||||
|
message_indexes = {index["name"] for index in self.inspector.get_indexes("messages")}
|
||||||
|
attachment_indexes = {index["name"] for index in self.inspector.get_indexes("attachments")}
|
||||||
|
invoice_indexes = {index["name"] for index in self.inspector.get_indexes("invoices")}
|
||||||
|
self.assertIn("ix_messages_request_created_id", message_indexes)
|
||||||
|
self.assertIn("ix_attachments_request_created_id", attachment_indexes)
|
||||||
|
self.assertIn("ix_invoices_request_issued_id", invoice_indexes)
|
||||||
|
|
||||||
def test_data_retention_policies_contains_core_columns(self):
|
def test_data_retention_policies_contains_core_columns(self):
|
||||||
columns = {column["name"] for column in self.inspector.get_columns("data_retention_policies")}
|
columns = {column["name"] for column in self.inspector.get_columns("data_retention_policies")}
|
||||||
self.assertIn("id", columns)
|
self.assertIn("id", columns)
|
||||||
|
|
|
||||||
|
|
@ -165,12 +165,18 @@ class UploadsS3Tests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(done_resp.status_code, 200)
|
self.assertEqual(done_resp.status_code, 200)
|
||||||
self.assertEqual(done_resp.json()["avatar_url"], f"s3://{key}")
|
self.assertEqual(done_resp.json()["avatar_url"], f"s3://{key}")
|
||||||
|
thumb_key = key.rsplit(".", 1)[0] + "__thumb.webp"
|
||||||
|
self.assertIn(thumb_key, fake_s3.objects)
|
||||||
|
|
||||||
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.assertNotEqual(view_resp.content, _AVATAR_PNG_1X1)
|
self.assertEqual(view_resp.content, _AVATAR_PNG_1X1)
|
||||||
self.assertIn("image/webp", view_resp.headers.get("content-type", ""))
|
self.assertIn("image/png", view_resp.headers.get("content-type", ""))
|
||||||
|
thumb_resp = self.client.get(f"/api/admin/uploads/object/{key}?token={token}&variant=thumb")
|
||||||
|
self.assertEqual(thumb_resp.status_code, 200)
|
||||||
|
self.assertNotEqual(thumb_resp.content, _AVATAR_PNG_1X1)
|
||||||
|
self.assertIn("image/webp", thumb_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))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue