From 6e2c91726995f96600b0cd0088ac96dd49e963ec Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:07:54 +0300 Subject: [PATCH] fix speed up 03 --- .../0035_add_workspace_perf_indexes.py | 45 +++++ app/api/admin/requests_modules/service.py | 48 +++-- app/api/admin/requests_modules/status_flow.py | 13 +- app/api/admin/uploads.py | 77 ++++++-- app/api/public/featured_staff.py | 43 ++++- app/models/attachment.py | 5 +- app/models/invoice.py | 5 +- app/models/message.py | 5 +- app/services/chat_secure_service.py | 7 +- app/services/notifications.py | 38 ++-- app/web/admin.js | 179 +++++++++++++++--- app/web/admin.jsx | 34 +++- app/web/admin/shared/utils.js | 11 +- context/19_performance_tracking_2026-03-16.md | 8 + tests/test_featured_staff_public.py | 2 +- tests/test_migrations.py | 10 +- tests/test_uploads_s3.py | 10 +- 17 files changed, 439 insertions(+), 101 deletions(-) create mode 100644 alembic/versions/0035_add_workspace_perf_indexes.py diff --git a/alembic/versions/0035_add_workspace_perf_indexes.py b/alembic/versions/0035_add_workspace_perf_indexes.py new file mode 100644 index 0000000..83bc63d --- /dev/null +++ b/alembic/versions/0035_add_workspace_perf_indexes.py @@ -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") diff --git a/app/api/admin/requests_modules/service.py b/app/api/admin/requests_modules/service.py index 1f4a931..4c61a8b 100644 --- a/app/api/admin/requests_modules/service.py +++ b/app/api/admin/requests_modules/service.py @@ -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), } diff --git a/app/api/admin/requests_modules/status_flow.py b/app/api/admin/requests_modules/status_flow.py index fe6c456..b9cff4e 100644 --- a/app/api/admin/requests_modules/status_flow.py +++ b/app/api/admin/requests_modules/status_flow.py @@ -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() diff --git a/app/api/admin/uploads.py b/app/api/admin/uploads.py index 7965e47..f4401ba 100644 --- a/app/api/admin/uploads.py +++ b/app/api/admin/uploads.py @@ -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, ) diff --git a/app/api/public/featured_staff.py b/app/api/public/featured_staff.py index 32b51f4..629aafd 100644 --- a/app/api/public/featured_staff.py +++ b/app/api/public/featured_staff.py @@ -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"): diff --git a/app/models/attachment.py b/app/models/attachment.py index 88c5643..6e7d07f 100644 --- a/app/models/attachment.py +++ b/app/models/attachment.py @@ -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) diff --git a/app/models/invoice.py b/app/models/invoice.py index ec1a72f..b6cdecc 100644 --- a/app/models/invoice.py +++ b/app/models/invoice.py @@ -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) diff --git a/app/models/message.py b/app/models/message.py index a2a456d..8bc2a6f 100644 --- a/app/models/message.py +++ b/app/models/message.py @@ -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) diff --git a/app/services/chat_secure_service.py b/app/services/chat_secure_service.py index 5c8c40d..66fe167 100644 --- a/app/services/chat_secure_service.py +++ b/app/services/chat_secure_service.py @@ -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]: diff --git a/app/services/notifications.py b/app/services/notifications.py index 72ff31a..c7d4a8d 100644 --- a/app/services/notifications.py +++ b/app/services/notifications.py @@ -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( diff --git a/app/web/admin.js b/app/web/admin.js index 0ae671c..fc2193c 100644 --- a/app/web/admin.js +++ b/app/web/admin.js @@ -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, diff --git a/app/web/admin.jsx b/app/web/admin.jsx index 9f60fe1..1c7b2bd 100644 --- a/app/web/admin.jsx +++ b/app/web/admin.jsx @@ -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 ( {canShowImage ? ( - {name setBroken(true)} /> + {name= 64 ? "low" : "auto"} + onError={() => setBroken(true)} + /> ) : ( {initials} )} @@ -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]); diff --git a/app/web/admin/shared/utils.js b/app/web/admin/shared/utils.js index fc20738..1b32300 100644 --- a/app/web/admin/shared/utils.js +++ b/app/web/admin/shared/utils.js @@ -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; } diff --git a/context/19_performance_tracking_2026-03-16.md b/context/19_performance_tracking_2026-03-16.md index 4d3c5db..cac6aae 100644 --- a/context/19_performance_tracking_2026-03-16.md +++ b/context/19_performance_tracking_2026-03-16.md @@ -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`. ## Дальше diff --git a/tests/test_featured_staff_public.py b/tests/test_featured_staff_public.py index 9003220..e66935a 100644 --- a/tests/test_featured_staff_public.py +++ b/tests/test_featured_staff_public.py @@ -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) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index cac08e4..68a8f31 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -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) diff --git a/tests/test_uploads_s3.py b/tests/test_uploads_s3.py index 571aa30..fa3011b 100644 --- a/tests/test_uploads_s3.py +++ b/tests/test_uploads_s3.py @@ -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))