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:
raise HTTPException(status_code=404, detail="Заявка не найдена")
ensure_lawyer_can_view_request_or_403(admin, req)
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 changed:
db.commit()
db.refresh(req)
_apply_request_open_side_effects(db, req, admin, mark_chat_read=False)
return _serialize_request_row(req)
def _serialize_request_row(req: Request) -> dict[str, Any]:
return {
"id": str(req.id),
"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]:
return {
"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]:
request_payload = get_request_service(request_id, db, admin)
request_uuid = request_uuid_or_400(request_id)
req = db.get(Request, request_uuid)
if req is None:
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(
db,
req.id,
@ -501,7 +513,7 @@ def get_request_workspace_service(request_id: str, db: Session, admin: dict) ->
"paid_total": paid_total,
"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,
db: Session,
admin: dict,
request_row: Request | None = None,
) -> dict[str, Any]:
request_uuid = request_uuid_or_400(request_id)
req = db.get(Request, request_uuid)
if not req:
raise HTTPException(status_code=404, detail="Заявка не найдена")
ensure_lawyer_can_view_request_or_403(admin, req)
req = request_row
if req is None:
request_uuid = request_uuid_or_400(request_id)
req = db.get(Request, request_uuid)
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()
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()
AVATAR_MAX_SIZE_PX = 512
AVATAR_THUMB_MAX_SIZE_PX = 160
AVATAR_WEBP_QUALITY = 80
_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)
except ClientError:
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")
if hasattr(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="Хранилище не поддерживает запись объектов")
def _normalize_avatar_to_webp_or_400(storage, *, key: str) -> tuple[int, str]:
source = _read_object_bytes_or_400(storage, key)
def _avatar_variant_key(key: str, variant: str) -> str:
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:
with Image.open(io.BytesIO(source)) as image:
image = ImageOps.exif_transpose(image)
image.load()
if max(image.size) > AVATAR_MAX_SIZE_PX:
image.thumbnail((AVATAR_MAX_SIZE_PX, AVATAR_MAX_SIZE_PX), resample=_AVATAR_RESAMPLE)
if max(image.size) > max_size_px:
image.thumbnail((max_size_px, max_size_px), resample=_AVATAR_RESAMPLE)
if image.mode != "RGB":
image = image.convert("RGB")
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="Не удалось обработать изображение аватара")
if not optimized:
raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара")
_write_object_bytes_or_500(storage, key=key, content=optimized, mime_type="image/webp")
return int(len(optimized)), "image/webp"
return optimized
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:
@ -401,7 +426,12 @@ def upload_complete(
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
_ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/")
optimized_size, optimized_mime = _normalize_avatar_to_webp_or_400(storage, key=payload.key)
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.responsible = responsible
db.add(user)
@ -415,10 +445,12 @@ def upload_complete(
allowed=True,
object_key=payload.key,
details={
"mime_type": optimized_mime,
"size_bytes": int(optimized_size),
"source_mime_type": payload.mime_type,
"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,
)
@ -466,9 +498,11 @@ def get_object_proxy(
object_key: str,
http_request: FastapiRequest,
token: str = Query(...),
variant: str | None = Query(None),
db: Session = Depends(get_db),
):
key = str(object_key or "").strip()
requested_variant = str(variant or "").strip().lower()
scope = "UNKNOWN"
scoped_uuid: uuid.UUID | None = None
actor_role = "UNKNOWN"
@ -521,10 +555,25 @@ def get_object_proxy(
raise HTTPException(status_code=404, detail="Файл не найден")
ensure_attachment_download_allowed_or_4xx(attachment)
try:
obj = get_s3_storage().get_object(key)
except ClientError:
raise HTTPException(status_code=404, detail="Файл не найден")
storage = get_s3_storage()
if scope == "avatars" and requested_variant == "thumb":
thumb_key = _avatar_variant_key(key, "thumb")
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(
db,
@ -536,7 +585,7 @@ def get_object_proxy(
allowed=True,
object_key=key,
request_id=scoped_uuid if scope == "requests" else None,
details={},
details={"variant": requested_variant or None},
responsible=responsible,
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.topic import Topic
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()
def _featured_avatar_proxy_path(admin_user_id: str) -> str:
return "/api/public/featured-staff/avatar/" + str(admin_user_id)
def _featured_avatar_proxy_path(admin_user_id: str, variant: str | None = "thumb") -> str:
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("")
@ -58,7 +69,7 @@ def list_featured_staff(
raw_avatar_url = str(user.avatar_url or "").strip()
avatar_url = raw_avatar_url
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(
{
"id": str(slot.id),
@ -80,6 +91,7 @@ def list_featured_staff(
@router.get("/avatar/{admin_user_id}")
def get_featured_staff_avatar(
admin_user_id: str,
variant: str | None = Query("thumb"),
db: Session = Depends(get_db),
):
try:
@ -110,10 +122,31 @@ def get_featured_staff_avatar(
if not key.startswith("avatars/" + str(user_uuid) + "/"):
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:
obj = get_s3_storage().get_object(key)
obj = storage.get_object(target_key)
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")
if body is None or not hasattr(body, "iter_chunks"):

View file

@ -1,7 +1,7 @@
import uuid
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.dialects.postgresql import UUID
from app.db.session import Base
@ -10,6 +10,9 @@ from app.models.common import UUIDMixin, TimestampMixin
class Attachment(Base, UUIDMixin, TimestampMixin):
__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)
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)

View file

@ -1,7 +1,7 @@
import uuid
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.orm import Mapped, mapped_column
@ -11,6 +11,9 @@ from app.models.common import TimestampMixin, UUIDMixin
class Invoice(Base, UUIDMixin, TimestampMixin):
__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)
client_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True, nullable=True)

View file

@ -1,7 +1,7 @@
import uuid
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.dialects.postgresql import UUID
from app.db.session import Base
@ -10,6 +10,9 @@ from app.models.common import UUIDMixin, TimestampMixin
class Message(Base, UUIDMixin, TimestampMixin):
__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)
author_type: Mapped[str] = mapped_column(String(20), nullable=False) # CLIENT|LAWYER|SYSTEM
author_name: Mapped[str | None] = mapped_column(String(200), nullable=True)

View file

@ -92,6 +92,7 @@ def _mark_counterparty_delivery(
request_id: Any,
recipient: str,
mark_read: bool,
commit: bool = True,
) -> bool:
side = str(recipient or "").strip().upper()
if side not in {"CLIENT", "STAFF"}:
@ -138,7 +139,7 @@ def _mark_counterparty_delivery(
if read_count:
changed = True
if changed:
if changed and commit:
db.commit()
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)
def mark_messages_read_for_staff(db: Session, *, request_id: Any) -> bool:
return _mark_counterparty_delivery(db, request_id=request_id, recipient="STAFF", mark_read=True)
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, commit=commit)
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)
if notification_id is not None:
query = query.filter(Notification.id == notification_id)
rows = query.all()
now = _as_utc_now()
for row in rows:
row.is_read = True
row.read_at = now
row.responsible = responsible
db.add(row)
return len(rows)
return int(
query.update(
{
Notification.is_read: True,
Notification.read_at: now,
Notification.responsible: responsible,
Notification.updated_at: now,
},
synchronize_session=False,
)
or 0
)
def mark_client_notifications_read(
@ -393,14 +398,19 @@ def mark_client_notifications_read(
query = query.filter(Notification.request_id == request_id)
if notification_id is not None:
query = query.filter(Notification.id == notification_id)
rows = query.all()
now = _as_utc_now()
for row in rows:
row.is_read = True
row.read_at = now
row.responsible = responsible
db.add(row)
return len(rows)
return int(
query.update(
{
Notification.is_read: True,
Notification.read_at: now,
Notification.responsible: responsible,
Notification.updated_at: now,
},
synchronize_session=False,
)
or 0
)
def list_admin_notifications(

View file

@ -2293,6 +2293,10 @@
currentImportantDateAt: "",
pendingStatusChangePreset: null,
messages: [],
messagesHasMore: false,
messagesLoadingMore: false,
messagesLoadedCount: 0,
messagesTotal: 0,
attachments: [],
messageDraft: "",
selectedFiles: [],
@ -2505,13 +2509,14 @@
for (let i = 0; i < text.length; i += 1) hash = hash * 31 + text.charCodeAt(i) >>> 0;
return palette[hash % palette.length];
}
function resolveAvatarSrc(avatarUrl, accessToken) {
function resolveAvatarSrc(avatarUrl, accessToken, size) {
const raw = String(avatarUrl || "").trim();
if (!raw) return "";
if (raw.startsWith("s3://")) {
const key = raw.slice("s3://".length);
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;
}
@ -3694,6 +3699,8 @@
currentImportantDateAt,
pendingStatusChangePreset,
messages,
messagesHasMore,
messagesLoadingMore,
attachments,
messageDraft,
selectedFiles,
@ -3701,6 +3708,7 @@
status,
onMessageChange,
onSendMessage,
onLoadOlderMessages,
onFilesSelect,
onRemoveSelectedFile,
onClearSelectedFiles,
@ -5066,7 +5074,16 @@
disabled: loading || fileUploading,
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(
"li",
{
@ -6250,7 +6267,11 @@
requestData: null,
financeSummary: null,
invoices: [],
statusRouteNodes: []
statusRouteNodes: [],
messagesHasMore: false,
messagesLoadingMore: false,
messagesLoadedCount: 0,
messagesTotal: 0
}));
}
try {
@ -6312,6 +6333,10 @@
availableStatuses: Array.isArray(statusRouteData?.available_statuses) ? statusRouteData.available_statuses : [],
currentImportantDateAt: String(statusRouteData?.current_important_date_at || rowData?.important_date_at || ""),
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,
selectedFiles: [],
fileUploading: false
@ -6330,6 +6355,10 @@
availableStatuses: [],
currentImportantDateAt: "",
messages: [],
messagesHasMore: false,
messagesLoadingMore: false,
messagesLoadedCount: 0,
messagesTotal: 0,
attachments: [],
selectedFiles: [],
fileUploading: false
@ -6478,17 +6507,57 @@
download_url: resolveAdminObjectSrc2(item?.s3_key, token)
}));
if (nextMessages.length || nextAttachments.length) {
setRequestModal((prev) => ({
...prev,
messages: mergeRowsById(prev.messages, nextMessages),
attachments: mergeRowsById(prev.attachments, nextAttachments)
}));
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,
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 };
},
[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(
async ({ typing } = {}) => {
const requestId = requestModal.requestId;
@ -6604,6 +6673,7 @@
submitRequestStatusChange,
submitRequestModalMessage,
probeRequestLive,
loadOlderRequestMessages,
setRequestTyping,
loadRequestDataTemplates,
loadRequestDataBatch,
@ -7074,9 +7144,19 @@
useEffect(() => setBroken(false), [avatarUrl]);
const initials = userInitials(name, email);
const bg = avatarColor(name || email || initials);
const src = resolveAvatarSrc(avatarUrl, accessToken);
const src = resolveAvatarSrc(avatarUrl, accessToken, size);
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 }) {
const [email, setEmail] = useState("");
@ -7505,6 +7585,7 @@
const [email, setEmail] = useState("");
const [userId, setUserId] = useState("");
const [activeSection, setActiveSection] = useState(initialSection);
const dashboardLoadRef = useRef(0);
const [dashboardData, setDashboardData] = useState({
scope: "",
cards: [],
@ -7625,6 +7706,7 @@
submitRequestStatusChange,
submitRequestModalMessage,
probeRequestLive,
loadOlderRequestMessages,
setRequestTyping,
loadRequestDataTemplates,
loadRequestDataBatch,
@ -8412,34 +8494,36 @@
}, [configActiveKey, dictionaries.topics, loadTable, statusDesignerTopicCode]);
const loadDashboard = useCallback(
async (tokenOverride) => {
const loadId = Date.now();
dashboardLoadRef.current = loadId;
setStatus("dashboard", "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...", "");
try {
const data = await api("/api/admin/metrics/overview", {}, tokenOverride);
const scope = String(data.scope || role || "");
const cards = scope === "LAWYER" ? [
{ label: "\u041C\u043E\u0438 \u0437\u0430\u044F\u0432\u043A\u0438", value: data.assigned_total ?? 0 },
{ label: "\u041C\u043E\u0438 \u0430\u043A\u0442\u0438\u0432\u043D\u044B\u0435", value: data.active_assigned_total ?? 0 },
{ label: "\u041D\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0435", value: data.unassigned_total ?? 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 }
const buildDashboardCards = (scope2, payload) => scope2 === "LAWYER" ? [
{ label: "\u041C\u043E\u0438 \u0437\u0430\u044F\u0432\u043A\u0438", value: payload.assigned_total ?? 0 },
{ label: "\u041C\u043E\u0438 \u0430\u043A\u0442\u0438\u0432\u043D\u044B\u0435", value: payload.active_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 \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: "\u041F\u0440\u043E\u0441\u0440\u043E\u0447\u0435\u043D\u043E SLA", value: payload.sla_overdue ?? 0 }
] : [
{ label: "\u041D\u043E\u0432\u044B\u0435", value: data.new ?? 0 },
{ label: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0435", value: data.assigned_total ?? 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: data.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: "\u0412\u044B\u0440\u0443\u0447\u043A\u0430 (\u043C\u0435\u0441.)", value: Number(data.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: "\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 \u043A\u043B\u0438\u0435\u043D\u0442\u0430\u043C\u0438", value: data.unread_for_clients ?? 0 }
{ label: "\u041D\u043E\u0432\u044B\u0435", value: payload.new ?? 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: payload.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: payload.my_unread_notifications_total ?? payload.my_unread_updates ?? 0 },
{ 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(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: 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: 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 = {};
Object.entries(data.by_status || {}).forEach(([code, count]) => {
localized[statusLabel(code)] = count;
});
setDashboardData({
scope,
cards,
cards: buildDashboardCards(scope, data),
byStatus: localized,
lawyerLoads: data.lawyer_loads || [],
myUnreadByEvent: data.my_unread_by_event || {},
@ -8453,6 +8537,17 @@
monthExpenses: Number(data.month_expenses || 0)
});
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) {
setStatus("dashboard", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error");
}
@ -9670,13 +9765,34 @@
useEffect(() => {
if (!token || !role) return;
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 () => {
if (!isRequestWorkspaceRoute && !routeInfo.section) {
if (!cancelled) await loadDashboard(token);
if (!cancelled) await loadTotpStatus(token);
if (!cancelled) deferredBootstrapCleanup = scheduleDeferredBootstrap();
return;
}
bootstrapReferenceData(token, role);
if (!cancelled && !isRequestWorkspaceRoute && !routeInfo.section) await loadDashboard(token);
if (!cancelled) await loadTotpStatus(token);
})();
return () => {
cancelled = true;
if (typeof deferredBootstrapCleanup === "function") deferredBootstrapCleanup();
};
}, [bootstrapReferenceData, isRequestWorkspaceRoute, loadDashboard, loadTotpStatus, role, routeInfo.section, token]);
useEffect(() => {
@ -10033,6 +10149,8 @@
currentImportantDateAt: requestModal.currentImportantDateAt || "",
pendingStatusChangePreset: requestModal.pendingStatusChangePreset,
messages: requestModal.messages || [],
messagesHasMore: Boolean(requestModal.messagesHasMore),
messagesLoadingMore: Boolean(requestModal.messagesLoadingMore),
attachments: requestModal.attachments || [],
messageDraft: requestModal.messageDraft || "",
selectedFiles: requestModal.selectedFiles || [],
@ -10040,6 +10158,7 @@
status: getStatus("requestModal"),
onMessageChange: updateRequestModalMessageDraft,
onSendMessage: submitRequestModalMessage,
onLoadOlderMessages: loadOlderRequestMessages,
onFilesSelect: appendRequestModalFiles,
onRemoveSelectedFile: removeRequestModalFile,
onClearSelectedFiles: clearRequestModalFiles,

View file

@ -274,12 +274,19 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
useEffect(() => setBroken(false), [avatarUrl]);
const initials = userInitials(name, email);
const bg = avatarColor(name || email || initials);
const src = resolveAvatarSrc(avatarUrl, accessToken);
const src = resolveAvatarSrc(avatarUrl, accessToken, size);
const canShowImage = Boolean(src && !broken);
return (
<span className="avatar" style={{ width: size + "px", height: size + "px", backgroundColor: bg }}>
{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>
)}
@ -3534,13 +3541,34 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
useEffect(() => {
if (!token || !role) return;
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 () => {
if (!isRequestWorkspaceRoute && !routeInfo.section) {
if (!cancelled) await loadDashboard(token);
if (!cancelled) await loadTotpStatus(token);
if (!cancelled) deferredBootstrapCleanup = scheduleDeferredBootstrap();
return;
}
bootstrapReferenceData(token, role);
if (!cancelled && !isRequestWorkspaceRoute && !routeInfo.section) await loadDashboard(token);
if (!cancelled) await loadTotpStatus(token);
})();
return () => {
cancelled = true;
if (typeof deferredBootstrapCleanup === "function") deferredBootstrapCleanup();
};
}, [bootstrapReferenceData, isRequestWorkspaceRoute, loadDashboard, loadTotpStatus, role, routeInfo.section, token]);

View file

@ -225,13 +225,20 @@ export function avatarColor(seed) {
return palette[hash % palette.length];
}
export function resolveAvatarSrc(avatarUrl, accessToken) {
export function resolveAvatarSrc(avatarUrl, accessToken, size) {
const raw = String(avatarUrl || "").trim();
if (!raw) return "";
if (raw.startsWith("s3://")) {
const key = raw.slice("s3://".length);
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;
}

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: `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-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)
row = payload["items"][0]
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):
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):
with self.engine.connect() as conn:
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):
tables = {
@ -209,6 +209,14 @@ class MigrationTests(unittest.TestCase):
indexes = {index["name"] for index in self.inspector.get_indexes("requests")}
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):
columns = {column["name"] for column in self.inspector.get_columns("data_retention_policies")}
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.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 ", "")
view_resp = self.client.get(f"/api/admin/uploads/object/{key}?token={token}")
self.assertEqual(view_resp.status_code, 200)
self.assertNotEqual(view_resp.content, _AVATAR_PNG_1X1)
self.assertIn("image/webp", view_resp.headers.get("content-type", ""))
self.assertEqual(view_resp.content, _AVATAR_PNG_1X1)
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:
refreshed = db.get(AdminUser, UUID(user_id))