fix speed up 03

This commit is contained in:
TronoSfera 2026-03-17 09:07:54 +03:00
parent 585b6bcfc1
commit 6e2c917269
17 changed files with 439 additions and 101 deletions

View 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")

View file

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

View file

@ -213,12 +213,15 @@ 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]:
request_uuid = request_uuid_or_400(request_id) req = request_row
req = db.get(Request, request_uuid) if req is None:
if not req: request_uuid = request_uuid_or_400(request_id)
raise HTTPException(status_code=404, detail="Заявка не найдена") req = db.get(Request, request_uuid)
ensure_lawyer_can_view_request_or_403(admin, req) if not req:
raise HTTPException(status_code=404, detail="Заявка не найдена")
ensure_lawyer_can_view_request_or_403(admin, req)
topic_code = str(req.topic_code or "").strip() topic_code = str(req.topic_code or "").strip()
current_status = str(req.status_code or "").strip() current_status = str(req.status_code or "").strip()

View file

@ -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,10 +555,25 @@ 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)
try: storage = get_s3_storage()
obj = get_s3_storage().get_object(key) if scope == "avatars" and requested_variant == "thumb":
except ClientError: thumb_key = _avatar_variant_key(key, "thumb")
raise HTTPException(status_code=404, detail="Файл не найден") try:
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:
raise HTTPException(status_code=404, detail="Файл не найден")
record_file_security_event( record_file_security_event(
db, db,
@ -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,
) )

View file

@ -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,10 +122,31 @@ 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:
target_key = _avatar_variant_key(key, "thumb")
except HTTPException:
target_key = key
storage = get_s3_storage()
try: try:
obj = get_s3_storage().get_object(key) obj = storage.get_object(target_key)
except ClientError: except ClientError:
raise HTTPException(status_code=404, detail="Аватар не найден") 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="Аватар не найден")
body = obj.get("Body") body = obj.get("Body")
if body is None or not hasattr(body, "iter_chunks"): if body is None or not hasattr(body, "iter_chunks"):

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) => {
...prev, const mergedMessages = mergeRowsById(prev.messages, nextMessages);
messages: mergeRowsById(prev.messages, nextMessages), const previousCount = Array.isArray(prev.messages) ? prev.messages.length : 0;
attachments: mergeRowsById(prev.attachments, nextAttachments) const addedCount = Math.max(0, mergedMessages.length - previousCount);
})); return {
...prev,
messages: mergedMessages,
messagesLoadedCount: Number(prev.messagesLoadedCount || previousCount) + addedCount,
messagesTotal: Number(prev.messagesTotal || previousCount) + addedCount,
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,

View file

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

View file

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

View file

@ -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`.
## Дальше ## Дальше

View file

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

View file

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

View file

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