From df1deece23ee6ef698e1bcd0788c451f6716e2cd Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 7 Aug 2026 16:24:49 +0300 Subject: [PATCH] perf(tasks): optimize large boards and media covers --- backend/Dockerfile | 2 +- backend/api/v1/tasks/serializers.py | 176 +++++++------ backend/api/v1/tasks/views.py | 237 +++++++++++++----- backend/tasks/media.py | 164 ++++++++++++ .../0035_taskattachment_thumbnail.py | 20 ++ backend/tasks/models.py | 6 + backend/tests/api/v1/tasks/test_tasks_api.py | 219 ++++++++++++---- frontend/src/app/tasks/page.tsx | 96 +++++-- .../src/components/tasks/TaskCoverPreview.tsx | 144 ++++++++--- frontend/src/lib/api/tasks.ts | 9 + frontend/src/types/api.ts | 1 + 11 files changed, 813 insertions(+), 261 deletions(-) create mode 100644 backend/tasks/media.py create mode 100644 backend/tasks/migrations/0035_taskattachment_thumbnail.py diff --git a/backend/Dockerfile b/backend/Dockerfile index 2236917f..914bd43f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,7 +4,7 @@ FROM python:3.13-slim WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 -RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev curl && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev curl ffmpeg && rm -rf /var/lib/apt/lists/* COPY backend/requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 5a1e4f3d..79e54e57 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -394,6 +394,7 @@ class TaskColumnRowSerializer(serializers.ModelSerializer): attrs["name"] = name return attrs + class TaskSerializer(serializers.ModelSerializer): created_by = EmployeeBriefSerializer(read_only=True) assignee = EmployeeBriefSerializer(read_only=True) @@ -527,83 +528,91 @@ class TaskSerializer(serializers.ModelSerializer): @extend_schema_field(serializers.IntegerField()) def get_linked_posts_count(self, obj): - if hasattr(obj, "linked_posts_count"): - return obj.linked_posts_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.POST, - ).count() + return self._get_linked_kind_count( + obj, + "linked_posts_count", + TaskLinkedObjectKind.POST, + ) @extend_schema_field(serializers.IntegerField()) def get_linked_messages_count(self, obj): - if hasattr(obj, "linked_messages_count"): - return obj.linked_messages_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.MESSAGE, - ).count() + return self._get_linked_kind_count( + obj, + "linked_messages_count", + TaskLinkedObjectKind.MESSAGE, + ) @extend_schema_field(serializers.IntegerField()) def get_linked_events_count(self, obj): - if hasattr(obj, "linked_events_count"): - return obj.linked_events_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.CALENDAR_EVENT, - ).count() + return self._get_linked_kind_count( + obj, + "linked_events_count", + TaskLinkedObjectKind.CALENDAR_EVENT, + ) @extend_schema_field(serializers.IntegerField()) def get_linked_documents_count(self, obj): - if hasattr(obj, "linked_documents_count"): - return obj.linked_documents_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.DOCUMENT, - ).count() + return self._get_linked_kind_count( + obj, + "linked_documents_count", + TaskLinkedObjectKind.DOCUMENT, + ) @extend_schema_field(serializers.IntegerField()) def get_linked_requests_count(self, obj): - if hasattr(obj, "linked_requests_count"): - return obj.linked_requests_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.REQUEST, - ).count() + return self._get_linked_kind_count( + obj, + "linked_requests_count", + TaskLinkedObjectKind.REQUEST, + ) @extend_schema_field(serializers.IntegerField()) def get_linked_procurement_requests_count(self, obj): - if hasattr(obj, "linked_procurement_requests_count"): - return obj.linked_procurement_requests_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, - ).count() + return self._get_linked_kind_count( + obj, + "linked_procurement_requests_count", + TaskLinkedObjectKind.PROCUREMENT_REQUEST, + ) @extend_schema_field(serializers.IntegerField()) def get_linked_employees_count(self, obj): - if hasattr(obj, "linked_employees_count"): - return obj.linked_employees_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.EMPLOYEE, - ).count() + return self._get_linked_kind_count( + obj, + "linked_employees_count", + TaskLinkedObjectKind.EMPLOYEE, + ) @extend_schema_field(serializers.IntegerField()) def get_linked_guests_count(self, obj): - if hasattr(obj, "linked_guests_count"): - return obj.linked_guests_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.GUEST, - ).count() + return self._get_linked_kind_count( + obj, + "linked_guests_count", + TaskLinkedObjectKind.GUEST, + ) @extend_schema_field(serializers.IntegerField()) def get_linked_guest_visits_count(self, obj): - if hasattr(obj, "linked_guest_visits_count"): - return obj.linked_guest_visits_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.GUEST_VISIT, - ).count() + return self._get_linked_kind_count( + obj, + "linked_guest_visits_count", + TaskLinkedObjectKind.GUEST_VISIT, + ) @extend_schema_field(serializers.IntegerField()) def get_linked_attendance_records_count(self, obj): - if hasattr(obj, "linked_attendance_records_count"): - return obj.linked_attendance_records_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.ATTENDANCE_RECORD, - ).count() + return self._get_linked_kind_count( + obj, + "linked_attendance_records_count", + TaskLinkedObjectKind.ATTENDANCE_RECORD, + ) + + def _get_linked_kind_count(self, obj, attribute_name, kind): + if hasattr(obj, attribute_name): + return getattr(obj, attribute_name) + prefetched = getattr(obj, "_prefetched_objects_cache", {}).get("linked_objects") + if prefetched is not None: + return sum(link.kind == kind for link in prefetched) + return obj.linked_objects.filter(kind=kind).count() @extend_schema_field(serializers.IntegerField()) def get_linked_objects_count(self, obj): @@ -676,9 +685,7 @@ class TaskSerializer(serializers.ModelSerializer): board = attrs.get("board") or getattr(self.instance, "board", None) column = attrs.get("column") or getattr(self.instance, "column", None) row = ( - attrs.get("row") - if "row" in attrs - else getattr(self.instance, "row", None) + attrs.get("row") if "row" in attrs else getattr(self.instance, "row", None) ) participants = attrs.get("participants") @@ -926,11 +933,13 @@ class TaskBoardSerializer(serializers.ModelSerializer): @extend_schema_field(TaskColumnRowSerializer(many=True)) def get_column_rows(self, obj): - rows = TaskColumnRow.objects.filter( - column__board=obj, - ).annotate( - tasks_count=Count("tasks", distinct=True) - ).order_by("column_id", "position", "id") + rows = ( + TaskColumnRow.objects.filter( + column__board=obj, + ) + .annotate(tasks_count=Count("tasks", distinct=True)) + .order_by("column_id", "position", "id") + ) return TaskColumnRowSerializer(rows, many=True).data @extend_schema_field(serializers.BooleanField()) @@ -998,7 +1007,9 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer): if self.instance: queryset = queryset.exclude(id=self.instance.id) if queryset.exists(): - raise serializers.ValidationError("Группа с таким названием уже существует.") + raise serializers.ValidationError( + "Группа с таким названием уже существует." + ) return name @extend_schema_field(serializers.BooleanField()) @@ -1165,6 +1176,7 @@ class TaskActivitySerializer(serializers.ModelSerializer): class TaskAttachmentSerializer(serializers.ModelSerializer): uploaded_by = EmployeeBriefSerializer(read_only=True) download_url = serializers.SerializerMethodField() + thumbnail_url = serializers.SerializerMethodField() class Meta: model = TaskAttachment @@ -1178,6 +1190,7 @@ class TaskAttachmentSerializer(serializers.ModelSerializer): "uploaded_by", "created_at", "download_url", + "thumbnail_url", ] read_only_fields = fields @@ -1190,6 +1203,19 @@ class TaskAttachmentSerializer(serializers.ModelSerializer): request = self.context.get("request") return request.build_absolute_uri(url) if request else url + @extend_schema_field(serializers.URLField(allow_null=True)) + def get_thumbnail_url(self, obj): + from tasks.media import task_attachment_media_kind + + if not task_attachment_media_kind(obj): + return None + url = reverse( + "api:v1:tasks:task-attachment-thumbnail", + kwargs={"pk": obj.task_id, "attachment_id": obj.id}, + ) + request = self.context.get("request") + return request.build_absolute_uri(url) if request else url + class TaskChecklistItemSerializer(serializers.ModelSerializer): created_by = EmployeeBriefSerializer(read_only=True) @@ -1283,11 +1309,7 @@ class TaskLinkedCalendarEventSerializer(serializers.ModelSerializer): event = getattr(obj, "content_object", None) request = self.context.get("request") user = getattr(request, "user", None) - if ( - obj.kind != TaskLinkedObjectKind.CALENDAR_EVENT - or event is None - or not user - ): + if obj.kind != TaskLinkedObjectKind.CALENDAR_EVENT or event is None or not user: return False return user_can_access_calendar_event(user, event) @@ -1341,9 +1363,7 @@ class TaskLinkedDocumentSerializer(serializers.ModelSerializer): "is_regulation": document.is_regulation, "folder_path": document.folder_path, "file_name": ( - getattr(document.file, "name", None) - if document.file - else None + getattr(document.file, "name", None) if document.file else None ), } @@ -1354,11 +1374,7 @@ class TaskLinkedDocumentSerializer(serializers.ModelSerializer): document = getattr(obj, "content_object", None) request = self.context.get("request") user = getattr(request, "user", None) - if ( - obj.kind != TaskLinkedObjectKind.DOCUMENT - or document is None - or not user - ): + if obj.kind != TaskLinkedObjectKind.DOCUMENT or document is None or not user: return False return user_can_access_document(user, document) @@ -1425,11 +1441,7 @@ class TaskLinkedRequestSerializer(serializers.ModelSerializer): request_obj = getattr(obj, "content_object", None) request = self.context.get("request") user = getattr(request, "user", None) - if ( - obj.kind != TaskLinkedObjectKind.REQUEST - or request_obj is None - or not user - ): + if obj.kind != TaskLinkedObjectKind.REQUEST or request_obj is None or not user: return False return user_can_access_employee_request(user, request_obj) @@ -1448,7 +1460,9 @@ def get_request_content_type(): class TaskLinkedProcurementRequestSerializer(serializers.ModelSerializer): created_by = EmployeeBriefSerializer(read_only=True) procurement_request = serializers.SerializerMethodField() - procurement_request_id = serializers.IntegerField(source="object_id", read_only=True) + procurement_request_id = serializers.IntegerField( + source="object_id", read_only=True + ) can_open = serializers.SerializerMethodField() object_url = serializers.SerializerMethodField() @@ -1880,11 +1894,7 @@ def _linked_object_cover_payload(link, context): image_url = build_media_url(source.image, context.get("request")) elif kind == TaskLinkedObjectKind.MESSAGE: - title = ( - source.author.get_full_name() - or source.author.email - or "Сообщение" - ) + title = source.author.get_full_name() or source.author.email or "Сообщение" description = source.content or "" elif kind == TaskLinkedObjectKind.CALENDAR_EVENT: title = getattr(source, "title", None) or "Календарное событие" diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 1e635764..d48f2f48 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -51,6 +51,7 @@ from tasks.access import ( from tasks.automation.engine import conditions_match, execute_automation from tasks.automation.schema import automation_catalog from tasks.comment_resources import add_comment_links_to_task +from tasks.media import ensure_task_attachment_thumbnail from tasks.models import ( Task, TaskActivity, @@ -66,6 +67,7 @@ from tasks.models import ( TaskBoardPlacement, TaskColumn, TaskColumnRow, + TaskChecklistItem, TaskCover, TaskCoverType, TaskExternalLink, @@ -160,7 +162,9 @@ def create_task_activity( def normalize_column_positions(column, row=None): task_ids = list( - column.tasks.filter(row=row).order_by("position", "-created_at", "id").values_list( + column.tasks.filter(row=row) + .order_by("position", "-created_at", "id") + .values_list( "id", flat=True, ) @@ -173,9 +177,7 @@ def send_task_checklist_update(task, event, item_id, *, cover_cleared=False): extra = { "task_id": task.id, "checklist_total": task.checklist_items.count(), - "checklist_completed": task.checklist_items.filter( - is_completed=True - ).count(), + "checklist_completed": task.checklist_items.filter(is_completed=True).count(), } if cover_cleared: extra["cover"] = None @@ -233,8 +235,9 @@ class TaskAutomationViewSet(viewsets.ModelViewSet): def _accessible_task(self, request, task_id): return get_object_or_404( - Task.objects.select_related("board", "column", "created_by", "assignee") - .filter( + Task.objects.select_related( + "board", "column", "created_by", "assignee" + ).filter( board__in=TaskBoard.objects.filter(task_board_access_q(request.user)) ), id=task_id, @@ -249,11 +252,20 @@ class TaskAutomationViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) task = self._accessible_task(request, task_id) - queryset = self.get_queryset().filter( - kind=TaskAutomationKind.BUTTON, - is_active=True, - ).filter(Q(applies_to_all_boards=True) | Q(boards=task.board_id)).distinct() - eligible = [automation for automation in queryset if conditions_match(task, automation.conditions)] + queryset = ( + self.get_queryset() + .filter( + kind=TaskAutomationKind.BUTTON, + is_active=True, + ) + .filter(Q(applies_to_all_boards=True) | Q(boards=task.board_id)) + .distinct() + ) + eligible = [ + automation + for automation in queryset + if conditions_match(task, automation.conditions) + ] return Response(self.get_serializer(eligible, many=True).data) @action(detail=True, methods=["post"]) @@ -303,10 +315,7 @@ class TaskBoardViewSet(viewsets.ModelViewSet): serializer_class = TaskBoardSerializer def get_serializer_class(self): - if ( - self.action == "list" - and self.request.query_params.get("view") == "summary" - ): + if self.action == "list" and self.request.query_params.get("view") == "summary": return TaskBoardSummarySerializer return super().get_serializer_class() @@ -364,8 +373,7 @@ class TaskBoardViewSet(viewsets.ModelViewSet): ) ) if not ( - self.action == "list" - and self.request.query_params.get("view") == "summary" + self.action == "list" and self.request.query_params.get("view") == "summary" ): queryset = queryset.prefetch_related( Prefetch( @@ -395,19 +403,95 @@ class TaskBoardViewSet(viewsets.ModelViewSet): "cover__external_link__created_by", "cover__linked_object", "cover__linked_object__content_type", - ).prefetch_related( + ) + .prefetch_related( "participants", "labels", - "linked_objects", - "external_links", - "attachments", - "checklist_items", + Prefetch( + "linked_objects", + queryset=TaskLinkedObject.objects.only( + "id", + "task_id", + "kind", + ), + ), + Prefetch( + "external_links", + queryset=TaskExternalLink.objects.only( + "id", + "task_id", + ), + ), + Prefetch( + "checklist_items", + queryset=TaskChecklistItem.objects.filter( + task__cover__kind=TaskCoverType.CHECKLIST, + ).only( + "id", + "task_id", + "title", + "is_completed", + "position", + ), + ), Prefetch( "cover__comment__task_attachments", queryset=TaskAttachment.objects.select_related( "uploaded_by" ), ), + ) + .annotate( + comments_count=Coalesce( + Subquery( + Message.objects.filter( + chat__type="comments", + chat__context_content_type=( + ContentType.objects.get_for_model(Task) + ), + chat__context_object_id=OuterRef("pk"), + is_deleted=False, + ) + .values("chat") + .annotate(count=Count("id")) + .values("count"), + output_field=IntegerField(), + ), + Value(0), + ), + attachments_count=Coalesce( + Subquery( + TaskAttachment.objects.filter(task_id=OuterRef("pk")) + .values("task_id") + .annotate(count=Count("id")) + .values("count"), + output_field=IntegerField(), + ), + Value(0), + ), + checklist_total=Coalesce( + Subquery( + TaskChecklistItem.objects.filter(task_id=OuterRef("pk")) + .values("task_id") + .annotate(count=Count("id")) + .values("count"), + output_field=IntegerField(), + ), + Value(0), + ), + checklist_completed=Coalesce( + Subquery( + TaskChecklistItem.objects.filter( + task_id=OuterRef("pk"), + is_completed=True, + ) + .values("task_id") + .annotate(count=Count("id")) + .values("count"), + output_field=IntegerField(), + ), + Value(0), + ), ), ), ) @@ -599,9 +683,7 @@ class TaskBoardViewSet(viewsets.ModelViewSet): placement.delete() else: placement.save(update_fields=["notifications_enabled", "updated_at"]) - return Response( - {"ok": True, "notifications_enabled": notifications_enabled} - ) + return Response({"ok": True, "notifications_enabled": notifications_enabled}) @action(detail=True, methods=["patch"], url_path="view-state") def view_state(self, request, pk=None): @@ -659,13 +741,13 @@ class TaskBoardViewSet(viewsets.ModelViewSet): board=board, defaults={"group": None}, ) - state = placement.view_state.copy() if isinstance(placement.view_state, dict) else {} + state = ( + placement.view_state.copy() + if isinstance(placement.view_state, dict) + else {} + ) key = state_keys[scope] - values = { - value - for value in state.get(key, []) - if isinstance(value, int) - } + values = {value for value in state.get(key, []) if isinstance(value, int)} if collapsed: values.add(object_id) else: @@ -686,9 +768,7 @@ class TaskBoardViewSet(viewsets.ModelViewSet): return Response( { "hidden_column_ids": state.get("hidden_column_ids", []), - "collapsed_base_column_ids": state.get( - "collapsed_base_column_ids", [] - ), + "collapsed_base_column_ids": state.get("collapsed_base_column_ids", []), "collapsed_row_ids": state.get("collapsed_row_ids", []), } ) @@ -721,7 +801,9 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet): ) def perform_create(self, serializer): - max_position = self.get_queryset().aggregate(value=Max("position"))["value"] or 0 + max_position = ( + self.get_queryset().aggregate(value=Max("position"))["value"] or 0 + ) serializer.save(user=self.request.user, position=max_position + 1000) @action(detail=True, methods=["post"], url_path="set-default") @@ -841,11 +923,7 @@ class TaskColumnViewSet(viewsets.ModelViewSet): ) if column.tasks.exists(): return Response( - { - "detail": ( - "Переместите задачи в другую колонку перед удалением." - ) - }, + {"detail": ("Переместите задачи в другую колонку перед удалением.")}, status=status.HTTP_400_BAD_REQUEST, ) if ( @@ -1043,9 +1121,7 @@ class TaskLabelViewSet(viewsets.ModelViewSet): send_task_board_update(board, "updated", "label", label.id) def perform_destroy(self, instance): - board_ids = list( - instance.tasks.values_list("board_id", flat=True).distinct() - ) + board_ids = list(instance.tasks.values_list("board_id", flat=True).distinct()) label_id = instance.id instance.delete() for board in TaskBoard.objects.filter(id__in=board_ids): @@ -1118,9 +1194,7 @@ class TaskViewSet(viewsets.ModelViewSet): ), linked_events_count=Count( "linked_objects", - filter=Q( - linked_objects__kind=TaskLinkedObjectKind.CALENDAR_EVENT - ), + filter=Q(linked_objects__kind=TaskLinkedObjectKind.CALENDAR_EVENT), distinct=True, ), linked_documents_count=Count( @@ -1140,9 +1214,7 @@ class TaskViewSet(viewsets.ModelViewSet): linked_procurement_requests_count=Count( "linked_objects", filter=Q( - linked_objects__kind=( - TaskLinkedObjectKind.PROCUREMENT_REQUEST - ), + linked_objects__kind=(TaskLinkedObjectKind.PROCUREMENT_REQUEST), ), distinct=True, ), @@ -1170,9 +1242,7 @@ class TaskViewSet(viewsets.ModelViewSet): linked_attendance_records_count=Count( "linked_objects", filter=Q( - linked_objects__kind=( - TaskLinkedObjectKind.ATTENDANCE_RECORD - ), + linked_objects__kind=(TaskLinkedObjectKind.ATTENDANCE_RECORD), ), distinct=True, ), @@ -1242,9 +1312,7 @@ class TaskViewSet(viewsets.ModelViewSet): "due_date": task.due_date.isoformat() if task.due_date else None, "row_id": task.row_id, "label_ids": sorted(task.labels.values_list("id", flat=True)), - "participant_ids": sorted( - task.participants.values_list("id", flat=True) - ), + "participant_ids": sorted(task.participants.values_list("id", flat=True)), } changed_fields = [ field for field, value in current.items() if previous[field] != value @@ -1275,9 +1343,9 @@ class TaskViewSet(viewsets.ModelViewSet): old_row = task.row if position in (None, ""): max_position = ( - Task.objects.filter(column=column, row=row).exclude(id=task.id).aggregate( - Max("position") - )["position__max"] + Task.objects.filter(column=column, row=row) + .exclude(id=task.id) + .aggregate(Max("position"))["position__max"] or 0 ) position = max_position + 1000 @@ -1569,8 +1637,7 @@ class TaskViewSet(viewsets.ModelViewSet): ) content_type = attachment.mime_type or ( - mimetypes.guess_type(attachment.file_name)[0] - or "application/octet-stream" + mimetypes.guess_type(attachment.file_name)[0] or "application/octet-stream" ) response = FileResponse( file_handle, @@ -1581,6 +1648,43 @@ class TaskViewSet(viewsets.ModelViewSet): response["X-Content-Type-Options"] = "nosniff" return response + @action( + detail=True, + methods=["get"], + url_path=r"attachments/(?P\d+)/thumbnail", + url_name="attachment-thumbnail", + ) + def attachment_thumbnail(self, request, pk=None, attachment_id=None): + task = self.get_object() + attachment = get_object_or_404( + task.attachments, + id=attachment_id, + ) + if not attachment.thumbnail and not ensure_task_attachment_thumbnail( + attachment + ): + return Response( + {"detail": "Для файла не удалось создать миниатюру."}, + status=status.HTTP_404_NOT_FOUND, + ) + try: + file_handle = attachment.thumbnail.open("rb") + except (FileNotFoundError, OSError, ValueError): + return Response( + {"detail": "Миниатюра не найдена в хранилище."}, + status=status.HTTP_404_NOT_FOUND, + ) + + response = FileResponse( + file_handle, + as_attachment=False, + filename=f"task-cover-{attachment.id}.webp", + content_type="image/webp", + ) + response["Cache-Control"] = "private, max-age=31536000, immutable" + response["X-Content-Type-Options"] = "nosniff" + return response + @action( detail=True, methods=["delete"], @@ -1655,10 +1759,7 @@ class TaskViewSet(viewsets.ModelViewSet): ) source_id = request.data.get("source_id") - if ( - kind != TaskCoverType.CHECKLIST - and not (attachment_id or source_id) - ): + if kind != TaskCoverType.CHECKLIST and not (attachment_id or source_id): return Response( {"source_id": ["Укажите источник обложки."]}, status=status.HTTP_400_BAD_REQUEST, @@ -1944,9 +2045,7 @@ class TaskViewSet(viewsets.ModelViewSet): serializer = TaskExternalLinkSerializer(link) return Response( serializer.data, - status=( - status.HTTP_201_CREATED if created else status.HTTP_200_OK - ), + status=(status.HTTP_201_CREATED if created else status.HTTP_200_OK), ) @action( @@ -3157,7 +3256,9 @@ class TaskViewSet(viewsets.ModelViewSet): ) return Response(status=status.HTTP_204_NO_CONTENT) - @action(detail=True, methods=["get", "post"], url_path="linked-procurement-requests") + @action( + detail=True, methods=["get", "post"], url_path="linked-procurement-requests" + ) def linked_procurement_requests(self, request, pk=None): task = self.get_object() if request.method == "GET": diff --git a/backend/tasks/media.py b/backend/tasks/media.py new file mode 100644 index 00000000..96473ca1 --- /dev/null +++ b/backend/tasks/media.py @@ -0,0 +1,164 @@ +import io +import logging +import shutil +import subprocess +import tempfile +from pathlib import Path + +from django.core.files.base import ContentFile +from PIL import Image, ImageOps + +from tasks.models import TaskAttachment + + +logger = logging.getLogger(__name__) + +TASK_COVER_THUMBNAIL_SIZE = (640, 280) +IMAGE_EXTENSIONS = { + ".avif", + ".bmp", + ".gif", + ".heic", + ".heif", + ".jpeg", + ".jpg", + ".png", + ".webp", +} +VIDEO_EXTENSIONS = { + ".m4v", + ".mkv", + ".mov", + ".mp4", + ".mpeg", + ".mpg", + ".ogv", + ".webm", +} + + +def task_attachment_media_kind(attachment: TaskAttachment) -> str | None: + mime_type = (attachment.mime_type or "").lower() + suffix = Path(attachment.file_name or "").suffix.lower() + if mime_type.startswith("image/") or suffix in IMAGE_EXTENSIONS: + return "image" + if mime_type.startswith("video/") or suffix in VIDEO_EXTENSIONS: + return "video" + return None + + +def _render_image_thumbnail(attachment: TaskAttachment) -> bytes: + with attachment.file.open("rb") as source: + image = Image.open(source) + image.seek(0) + image = ImageOps.exif_transpose(image) + if image.mode not in ("RGB", "RGBA"): + image = image.convert("RGBA" if "transparency" in image.info else "RGB") + if image.mode == "RGBA": + background = Image.new("RGB", image.size, (20, 27, 36)) + background.paste(image, mask=image.getchannel("A")) + image = background + image = ImageOps.fit( + image, + TASK_COVER_THUMBNAIL_SIZE, + method=Image.Resampling.LANCZOS, + ) + output = io.BytesIO() + image.save(output, format="WEBP", quality=84, method=5) + return output.getvalue() + + +def _render_video_thumbnail(attachment: TaskAttachment) -> bytes: + ffmpeg = shutil.which("ffmpeg") + if not ffmpeg: + raise RuntimeError("ffmpeg is not installed") + + input_suffix = Path(attachment.file_name or "").suffix or ".video" + input_path = "" + temporary_input = False + output_path = "" + try: + try: + input_path = attachment.file.path + except (AttributeError, NotImplementedError): + with tempfile.NamedTemporaryFile( + suffix=input_suffix, delete=False + ) as source_file: + input_path = source_file.name + temporary_input = True + with attachment.file.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + source_file.write(chunk) + with tempfile.NamedTemporaryFile(suffix=".webp", delete=False) as output_file: + output_path = output_file.name + + filter_value = "scale=640:280:force_original_aspect_ratio=increase,crop=640:280" + command = [ + ffmpeg, + "-hide_banner", + "-loglevel", + "error", + "-ss", + "0.5", + "-i", + input_path, + "-frames:v", + "1", + "-vf", + filter_value, + "-c:v", + "libwebp", + "-quality", + "84", + "-y", + output_path, + ] + result = subprocess.run( + command, + capture_output=True, + check=False, + timeout=30, + ) + if result.returncode != 0: + command[5] = "0" + result = subprocess.run( + command, + capture_output=True, + check=False, + timeout=30, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.decode("utf-8", errors="replace")) + return Path(output_path).read_bytes() + finally: + if temporary_input and input_path: + Path(input_path).unlink(missing_ok=True) + if output_path: + Path(output_path).unlink(missing_ok=True) + + +def ensure_task_attachment_thumbnail(attachment: TaskAttachment) -> bool: + if attachment.thumbnail: + return True + media_kind = task_attachment_media_kind(attachment) + if not media_kind: + return False + + try: + thumbnail = ( + _render_image_thumbnail(attachment) + if media_kind == "image" + else _render_video_thumbnail(attachment) + ) + attachment.thumbnail.save( + f"task-{attachment.task_id}-attachment-{attachment.id}.webp", + ContentFile(thumbnail), + save=True, + ) + return True + except Exception: + logger.exception( + "Could not generate thumbnail for task attachment %s", + attachment.id, + ) + return False diff --git a/backend/tasks/migrations/0035_taskattachment_thumbnail.py b/backend/tasks/migrations/0035_taskattachment_thumbnail.py new file mode 100644 index 00000000..4d573834 --- /dev/null +++ b/backend/tasks/migrations/0035_taskattachment_thumbnail.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0034_cleanup_empty_checklist_covers"), + ] + + operations = [ + migrations.AddField( + model_name="taskattachment", + name="thumbnail", + field=models.ImageField( + blank=True, + null=True, + upload_to="task_attachment_thumbnails/%Y/%m/%d/", + verbose_name="Миниатюра", + ), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index f020f33f..f2040718 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -551,6 +551,12 @@ class TaskAttachment(models.Model): "Файл", upload_to="task_attachments/%Y/%m/%d/", ) + thumbnail = models.ImageField( + "Миниатюра", + upload_to="task_attachment_thumbnails/%Y/%m/%d/", + null=True, + blank=True, + ) file_name = models.CharField("Название файла", max_length=255) file_size = models.PositiveBigIntegerField("Размер файла (байты)") mime_type = models.CharField("MIME-тип", max_length=255, blank=True) diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 9728677f..12387686 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -1,12 +1,16 @@ import base64 +import io from datetime import date, timedelta from pathlib import Path from unittest.mock import patch import pytest +from PIL import Image from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.files.uploadedfile import SimpleUploadedFile +from django.db import connection +from django.test.utils import CaptureQueriesContext from django.urls import reverse from django.utils import timezone from rest_framework import status @@ -98,11 +102,63 @@ def test_default_board_creates_work_columns(api_client, user): assert TaskColumn.objects.count() == 4 +def test_board_detail_query_count_does_not_scale_with_task_count(api_client, user): + board = TaskBoard.objects.create(name="Большая доска", created_by=user) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + first_task = Task.objects.create( + board=board, + column=column, + title="Первая задача", + created_by=user, + ) + TaskChecklistItem.objects.create( + task=first_task, + title="Пункт без обложки", + position=1000, + is_completed=True, + created_by=user, + ) + TaskExternalLink.objects.create( + task=first_task, + url="https://example.com/performance", + title="Ссылка", + created_by=user, + ) + api_client.force_authenticate(user=user) + url = reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}) + api_client.get(url) + + with CaptureQueriesContext(connection) as small_board_queries: + small_response = api_client.get(url) + assert small_response.status_code == status.HTTP_200_OK + serialized_first_task = next( + task for task in small_response.data["tasks"] if task["id"] == first_task.id + ) + assert serialized_first_task["checklist_total"] == 1 + assert serialized_first_task["checklist_completed"] == 1 + assert serialized_first_task["linked_objects_count"] == 1 + + Task.objects.bulk_create( + [ + Task( + board=board, + column=column, + title=f"Задача {index}", + created_by=user, + ) + for index in range(2, 32) + ] + ) + with CaptureQueriesContext(connection) as large_board_queries: + large_response = api_client.get(url) + + assert large_response.status_code == status.HTTP_200_OK + assert len(large_board_queries) <= len(small_board_queries) + 1 + + def test_user_can_set_personal_default_board(api_client, user): api_client.force_authenticate(user=user) - first_board = api_client.get( - reverse("api:v1:tasks:task-board-default") - ).data + first_board = api_client.get(reverse("api:v1:tasks:task-board-default")).data second_board = TaskBoard.objects.create( name="Персональная доска по умолчанию", created_by=user, @@ -333,9 +389,7 @@ def test_user_can_toggle_personal_default_board_group(api_client, user): assert set_response.data["group"]["is_default_for_current_user"] is True assert TaskUserSettings.objects.get(user=user).default_group_id == first_group.id - list_response = api_client.get( - reverse("api:v1:tasks:task-board-group-list") - ) + list_response = api_client.get(reverse("api:v1:tasks:task-board-group-list")) groups_by_id = {item["id"]: item for item in list_response.data["results"]} assert groups_by_id[first_group.id]["is_default_for_current_user"] is True assert groups_by_id[second_group.id]["is_default_for_current_user"] is False @@ -604,6 +658,7 @@ def test_board_view_state_rejects_objects_from_another_board(api_client, user): assert response.status_code == status.HTTP_400_BAD_REQUEST assert not TaskBoardPlacement.objects.filter(user=user, board=board).exists() + def test_user_cannot_set_inaccessible_board_as_default(api_client): owner = make_user("task-default-owner@example.com", "+79994440091") outsider = make_user("task-default-outsider@example.com", "+79994440092") @@ -979,8 +1034,12 @@ def test_task_row_must_belong_to_tasks_top_level_column(api_client, user): def test_task_moves_between_rows_without_changing_column(api_client, user): board = TaskBoard.objects.create(name="Строки ячеек", created_by=user) parent = TaskColumn.objects.create(board=board, name="Производство", position=1000) - first_row = TaskColumnRow.objects.create(column=parent, name="Линия 1", position=1000) - second_row = TaskColumnRow.objects.create(column=parent, name="Линия 2", position=2000) + first_row = TaskColumnRow.objects.create( + column=parent, name="Линия 1", position=1000 + ) + second_row = TaskColumnRow.objects.create( + column=parent, name="Линия 2", position=2000 + ) child = TaskColumn.objects.create( board=board, parent=parent, @@ -1147,9 +1206,7 @@ def test_user_cannot_delete_occupied_or_last_task_column(api_client, user): def test_create_task_and_move_to_done_column(api_client, user): api_client.force_authenticate(user=user) - board_response = api_client.get( - reverse("api:v1:tasks:task-board-default") - ) + board_response = api_client.get(reverse("api:v1:tasks:task-board-default")) board_id = board_response.data["id"] columns = {column["name"]: column for column in board_response.data["columns"]} @@ -1269,7 +1326,9 @@ def test_member_can_claim_unassigned_task(api_client, user): reverse("api:v1:tasks:task-claim", kwargs={"pk": completed_task.id}) ) assert completed_response.status_code == status.HTTP_400_BAD_REQUEST - assert completed_response.data["detail"] == "Нельзя взять в работу завершённую задачу." + assert ( + completed_response.data["detail"] == "Нельзя взять в работу завершённую задачу." + ) completed_task.refresh_from_db() assert completed_task.assignee_id is None @@ -1318,11 +1377,14 @@ def test_only_assignee_can_complete_task_in_configured_done_column(api_client, u configured_done_column.refresh_from_db() assert previous_done_column.is_done is False assert configured_done_column.is_done is True - assert TaskColumn.objects.filter( - board=board, - parent__isnull=True, - is_done=True, - ).count() == 1 + assert ( + TaskColumn.objects.filter( + board=board, + parent__isnull=True, + is_done=True, + ).count() + == 1 + ) api_client.force_authenticate(user=member) forbidden_response = api_client.post(complete_url) @@ -1617,7 +1679,9 @@ def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared( ) second_image = TaskAttachment.objects.create( task=task, - file=SimpleUploadedFile("second.jpg", b"second image", content_type="image/jpeg"), + file=SimpleUploadedFile( + "second.jpg", b"second image", content_type="image/jpeg" + ), file_name="second.jpg", file_size=12, mime_type="image/jpeg", @@ -1666,6 +1730,9 @@ def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared( item for item in board_response.data["tasks"] if item["id"] == task.id ) assert serialized_task["cover_attachment"]["id"] == second_image.id + assert serialized_task["cover_attachment"]["thumbnail_url"].endswith( + f"/api/v1/tasks/{task.id}/attachments/{second_image.id}/thumbnail/" + ) video_response = api_client.post( cover_url, @@ -1702,6 +1769,61 @@ def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared( assert not TaskCover.objects.filter(task=task).exists() +def test_task_attachment_thumbnail_is_generated_once_and_requires_access( + api_client, + user, + tmp_path, + settings, +): + settings.MEDIA_ROOT = tmp_path + outsider = make_user("thumbnail-outsider@example.com", "+79994440126") + board = TaskBoard.objects.create( + name="Доска миниатюр", + created_by=user, + access_scope="restricted", + ) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + task = Task.objects.create( + board=board, + column=column, + title="Задача с миниатюрой", + created_by=user, + ) + image_buffer = io.BytesIO() + Image.new("RGB", (80, 40), (20, 140, 220)).save(image_buffer, format="PNG") + image_data = image_buffer.getvalue() + attachment = TaskAttachment.objects.create( + task=task, + file=SimpleUploadedFile("cover.png", image_data, content_type="image/png"), + file_name="cover.png", + file_size=len(image_data), + mime_type="image/png", + uploaded_by=user, + ) + thumbnail_url = reverse( + "api:v1:tasks:task-attachment-thumbnail", + kwargs={"pk": task.id, "attachment_id": attachment.id}, + ) + + api_client.force_authenticate(user=user) + first_response = api_client.get(thumbnail_url) + + assert first_response.status_code == status.HTTP_200_OK + assert first_response["Content-Type"] == "image/webp" + assert b"WEBP" in b"".join(first_response.streaming_content)[:16] + attachment.refresh_from_db() + saved_thumbnail_name = attachment.thumbnail.name + assert saved_thumbnail_name.endswith(".webp") + + second_response = api_client.get(thumbnail_url) + assert second_response.status_code == status.HTTP_200_OK + attachment.refresh_from_db() + assert attachment.thumbnail.name == saved_thumbnail_name + + api_client.force_authenticate(user=outsider) + assert api_client.get(thumbnail_url).status_code == status.HTTP_404_NOT_FOUND + + def test_task_cover_supports_checklist_comment_link_and_linked_object( api_client, user, @@ -1815,9 +1937,7 @@ def test_task_cover_supports_checklist_comment_link_and_linked_object( format="json", ) assert link_response.status_code == status.HTTP_200_OK - assert link_response.data["cover"]["external_link"]["title"] == ( - "Пример ссылки" - ) + assert link_response.data["cover"]["external_link"]["title"] == ("Пример ссылки") object_response = api_client.post( cover_url, @@ -2075,9 +2195,7 @@ def test_private_board_is_available_only_to_owner_and_admin(api_client): api_client.force_authenticate(user=hidden_user) list_response = api_client.get(reverse("api:v1:tasks:task-board-list")) assert list_response.status_code == status.HTTP_200_OK - assert board.id not in { - item["id"] for item in list_response.data["results"] - } + assert board.id not in {item["id"] for item in list_response.data["results"]} detail_response = api_client.get( reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}) ) @@ -2423,9 +2541,7 @@ def test_task_linked_document_respects_document_access(api_client): assert link_response.data["document"]["title"] == "Регламент доступа" assert link_response.data["document"]["is_regulation"] is True assert link_response.data["can_open"] is True - assert link_response.data["object_url"] == ( - f"/documents?document={document.id}" - ) + assert link_response.data["object_url"] == (f"/documents?document={document.id}") api_client.force_authenticate(user=board_member) linked_response = api_client.get( @@ -2451,9 +2567,7 @@ def test_task_linked_document_respects_document_access(api_client): assert reverse_linked_response.status_code == status.HTTP_200_OK assert reverse_linked_response.data[0]["id"] == task.id assert reverse_linked_response.data[0]["title"] == "Проверить регламент" - assert ( - reverse_linked_response.data[0]["linked_documents_count"] == 1 - ) + assert reverse_linked_response.data[0]["linked_documents_count"] == 1 def test_task_linked_request_respects_request_access(api_client): @@ -2528,9 +2642,7 @@ def test_task_linked_request_respects_request_access(api_client): assert reverse_linked_response.status_code == status.HTTP_200_OK assert reverse_linked_response.data[0]["id"] == task.id assert reverse_linked_response.data[0]["title"] == "Проверить заявление" - assert ( - reverse_linked_response.data[0]["linked_requests_count"] == 1 - ) + assert reverse_linked_response.data[0]["linked_requests_count"] == 1 def test_task_linked_procurement_request_respects_procurement_access(api_client): @@ -2585,7 +2697,9 @@ def test_task_linked_procurement_request_respects_procurement_access(api_client) ) assert link_response.status_code == status.HTTP_201_CREATED assert link_response.data["procurement_request"]["title"] == "Закупка мониторов" - assert link_response.data["procurement_request"]["status"] == ProcurementStatus.DRAFT + assert ( + link_response.data["procurement_request"]["status"] == ProcurementStatus.DRAFT + ) assert link_response.data["procurement_request"]["urgency"] == UrgencyLevel.HIGH assert link_response.data["can_open"] is True assert link_response.data["object_url"] == ( @@ -2991,9 +3105,7 @@ def test_task_linked_attendance_record_respects_record_access(api_client): def test_task_activity_records_core_actions(api_client, user): api_client.force_authenticate(user=user) - board_response = api_client.get( - reverse("api:v1:tasks:task-board-default") - ) + board_response = api_client.get(reverse("api:v1:tasks:task-board-default")) board_id = board_response.data["id"] columns = board_response.data["columns"] source_column = columns[0] @@ -3206,9 +3318,7 @@ def test_task_comments_use_communications_and_update_count(api_client, user): ) assert detail_response.data["comments_count"] == 1 - delete_response = api_client.delete( - comment_detail_url - ) + delete_response = api_client.delete(comment_detail_url) assert delete_response.status_code == status.HTTP_204_NO_CONTENT message.refresh_from_db() assert message.is_deleted is True @@ -3228,12 +3338,18 @@ def test_task_comments_use_communications_and_update_count(api_client, user): "comment_added", ] - assert api_client.get( - reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}) - ).data == [] - assert api_client.get( - reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}) - ).data["comments_count"] == 0 + assert ( + api_client.get( + reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}) + ).data + == [] + ) + assert ( + api_client.get( + reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}) + ).data["comments_count"] + == 0 + ) def test_task_comments_chat_access_follows_board_access(api_client): @@ -3315,10 +3431,13 @@ def test_task_comment_files_and_links_become_task_resources(api_client, user): assert attachment.comment_id == response.data["id"] assert attachment.mime_type == "image/png" assert attachment_data["comment"] == response.data["id"] - assert TaskExternalLink.objects.filter( - task=task, - url="https://example.com/mockup", - ).count() == 1 + assert ( + TaskExternalLink.objects.filter( + task=task, + url="https://example.com/mockup", + ).count() + == 1 + ) listed = api_client.get( reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}) diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index eb7b2535..25691e4e 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -67,6 +67,7 @@ import { } from "lucide-react"; import { Suspense, + memo, useCallback, useEffect, useMemo, @@ -612,7 +613,7 @@ function getMessageCreatedLabel(message?: Message | null) { return message.created || (message.created_at ? formatDate(message.created_at) : ""); } -function TaskCardView({ +const TaskCardView = memo(function TaskCardView({ task, onOpen, onEdit, @@ -624,7 +625,7 @@ function TaskCardView({ onComplete, menuOpen, menuRef, - onToggleMenu, + onToggleTaskMenu, }: { task: TaskCard; onOpen: (task: TaskCard) => void; @@ -637,7 +638,7 @@ function TaskCardView({ onComplete: (task: TaskCard) => void; menuOpen: boolean; menuRef: RefObject; - onToggleMenu: () => void; + onToggleTaskMenu: (taskId: number) => void; }) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: `task-${task.id}`, @@ -689,7 +690,7 @@ function TaskCardView({ >