From bfb175088c1a20c5a0d893b2d4521e5222b1627b Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 6 Aug 2026 10:39:33 +0300 Subject: [PATCH] feat(tasks): add task card covers --- backend/api/v1/tasks/serializers.py | 27 ++++ backend/api/v1/tasks/views.py | 87 +++++++++++- backend/tasks/admin.py | 11 +- .../0029_taskattachment_is_cover.py | 23 ++++ backend/tasks/models.py | 8 ++ backend/tasks/notifications/config.py | 1 + backend/tasks/notifications/handlers.py | 8 +- backend/tests/api/v1/tasks/test_tasks_api.py | 101 ++++++++++++++ frontend/src/app/tasks/page.tsx | 130 +++++++++++++++++- .../src/components/tasks/TaskCoverImage.tsx | 77 +++++++++++ frontend/src/lib/api/tasks.ts | 11 +- frontend/src/types/api.ts | 2 + 12 files changed, 477 insertions(+), 9 deletions(-) create mode 100644 backend/tasks/migrations/0029_taskattachment_is_cover.py create mode 100644 frontend/src/components/tasks/TaskCoverImage.tsx diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index a64e8034..c1e823df 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -304,6 +304,7 @@ class TaskSerializer(serializers.ModelSerializer): linked_objects_count = serializers.SerializerMethodField() comments_count = serializers.SerializerMethodField() attachments_count = serializers.SerializerMethodField() + cover_attachment = serializers.SerializerMethodField() checklist_total = serializers.SerializerMethodField() checklist_completed = serializers.SerializerMethodField() @@ -346,6 +347,7 @@ class TaskSerializer(serializers.ModelSerializer): "linked_objects_count", "comments_count", "attachments_count", + "cover_attachment", "checklist_total", "checklist_completed", "created_at", @@ -375,6 +377,7 @@ class TaskSerializer(serializers.ModelSerializer): "linked_objects_count", "comments_count", "attachments_count", + "cover_attachment", "checklist_total", "checklist_completed", "created_at", @@ -479,6 +482,29 @@ class TaskSerializer(serializers.ModelSerializer): return obj.attachments_count return obj.attachments.count() + @extend_schema_field(serializers.DictField(allow_null=True)) + def get_cover_attachment(self, obj): + cover_attachments = getattr(obj, "cover_attachments", None) + if cover_attachments is not None: + attachment = cover_attachments[0] if cover_attachments else None + else: + prefetched = getattr(obj, "_prefetched_objects_cache", {}).get( + "attachments" + ) + if prefetched is not None: + attachment = next( + (item for item in prefetched if item.is_cover), + None, + ) + else: + attachment = obj.attachments.filter(is_cover=True).first() + if attachment is None: + return None + return TaskAttachmentSerializer( + attachment, + context=self.context, + ).data + @extend_schema_field(serializers.IntegerField()) def get_checklist_total(self, obj): if hasattr(obj, "checklist_total"): @@ -1002,6 +1028,7 @@ class TaskAttachmentSerializer(serializers.ModelSerializer): "file_name", "file_size", "mime_type", + "is_cover", "uploaded_by", "created_at", "download_url", diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index d173c04a..687eb3fc 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -935,7 +935,18 @@ class TaskViewSet(viewsets.ModelViewSet): ) ) .select_related("board", "column", "row", "created_by", "assignee") - .prefetch_related("labels", "participants", "checklist_items") + .prefetch_related( + "labels", + "participants", + "checklist_items", + Prefetch( + "attachments", + queryset=TaskAttachment.objects.filter( + is_cover=True + ).select_related("uploaded_by"), + to_attr="cover_attachments", + ), + ) .annotate( linked_posts_count=Count( "linked_objects", @@ -1422,6 +1433,7 @@ class TaskViewSet(viewsets.ModelViewSet): ) attachment_id = attachment.id file_name = attachment.file_name + was_cover = attachment.is_cover attachment.delete() create_task_activity( task, @@ -1435,10 +1447,81 @@ class TaskViewSet(viewsets.ModelViewSet): "attachments_updated", "task", task.id, - extra={"attachments_count": task.attachments.count()}, + extra={ + "attachments_count": task.attachments.count(), + **({"cover_attachment_id": None} if was_cover else {}), + }, ) return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True, methods=["post", "delete"], url_path="cover") + def cover(self, request, pk=None): + task = self.get_object() + attachment_id = request.data.get("attachment_id") + if request.method == "POST" and not attachment_id: + return Response( + {"attachment_id": ["Это поле обязательно."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + with transaction.atomic(): + locked_task = Task.objects.select_for_update().get(pk=task.pk) + previous_id = ( + locked_task.attachments.filter(is_cover=True) + .values_list("id", flat=True) + .first() + ) + next_attachment = None + if request.method == "POST": + next_attachment = get_object_or_404( + locked_task.attachments, + id=attachment_id, + ) + content_type = next_attachment.mime_type or ( + mimetypes.guess_type(next_attachment.file_name)[0] or "" + ) + if not content_type.lower().startswith("image/"): + return Response( + {"attachment_id": ["Обложкой может быть только изображение."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + next_id = next_attachment.id if next_attachment else None + if previous_id != next_id: + locked_task.attachments.filter(is_cover=True).update(is_cover=False) + if next_attachment: + TaskAttachment.objects.filter(pk=next_attachment.pk).update( + is_cover=True + ) + locked_task.save(update_fields=["updated_at"]) + + if previous_id != next_id: + create_task_activity( + locked_task, + request.user, + TaskActivityAction.UPDATED, + metadata={ + "fields": ["cover_attachment_id"], + "cover_attachment_id": next_id, + }, + ) + notify_task_updated( + locked_task, + request.user, + {"cover_attachment_id": previous_id}, + {"cover_attachment_id": next_id}, + ) + send_task_board_update( + locked_task.board, + "updated", + "task", + locked_task.id, + extra={"cover_attachment_id": next_id}, + ) + + refreshed_task = self.get_queryset().get(pk=locked_task.pk) + return Response(self.get_serializer(refreshed_task).data) + @action(detail=True, methods=["get", "post"], url_path="checklist") def checklist(self, request, pk=None): task = self.get_object() diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index 09c18563..50d63e84 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -114,8 +114,15 @@ class TaskAdmin(admin.ModelAdmin): @admin.register(TaskAttachment) class TaskAttachmentAdmin(admin.ModelAdmin): - list_display = ["file_name", "task", "uploaded_by", "file_size", "created_at"] - list_filter = ["created_at"] + list_display = [ + "file_name", + "task", + "is_cover", + "uploaded_by", + "file_size", + "created_at", + ] + list_filter = ["is_cover", "created_at"] search_fields = ["file_name", "task__title", "uploaded_by__email"] autocomplete_fields = ["task", "uploaded_by"] diff --git a/backend/tasks/migrations/0029_taskattachment_is_cover.py b/backend/tasks/migrations/0029_taskattachment_is_cover.py new file mode 100644 index 00000000..01fa1ec0 --- /dev/null +++ b/backend/tasks/migrations/0029_taskattachment_is_cover.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0028_unique_done_column_per_board"), + ] + + operations = [ + migrations.AddField( + model_name="taskattachment", + name="is_cover", + field=models.BooleanField(default=False, verbose_name="Обложка задачи"), + ), + migrations.AddConstraint( + model_name="taskattachment", + constraint=models.UniqueConstraint( + condition=models.Q(is_cover=True), + fields=("task",), + name="uniq_task_cover_attachment", + ), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 45a1fb9e..af9585a4 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -381,6 +381,7 @@ class TaskAttachment(models.Model): file_name = models.CharField("Название файла", max_length=255) file_size = models.PositiveBigIntegerField("Размер файла (байты)") mime_type = models.CharField("MIME-тип", max_length=255, blank=True) + is_cover = models.BooleanField("Обложка задачи", default=False) uploaded_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, @@ -394,6 +395,13 @@ class TaskAttachment(models.Model): verbose_name_plural = "Вложения задач" ordering = ["created_at", "id"] indexes = [models.Index(fields=["task", "created_at"])] + constraints = [ + models.UniqueConstraint( + fields=["task"], + condition=models.Q(is_cover=True), + name="uniq_task_cover_attachment", + ) + ] def __str__(self): return f"{self.task}: {self.file_name}" diff --git a/backend/tasks/notifications/config.py b/backend/tasks/notifications/config.py index 6b9dc0d4..96bbe657 100644 --- a/backend/tasks/notifications/config.py +++ b/backend/tasks/notifications/config.py @@ -123,6 +123,7 @@ class MessageTemplates: "priority": "приоритет", "label_ids": "метки", "participant_ids": "участников", + "cover_attachment_id": "обложку", } changed = ", ".join(field_names.get(field, field) for field in fields) return ( diff --git a/backend/tasks/notifications/handlers.py b/backend/tasks/notifications/handlers.py index 1363580b..5386dca0 100644 --- a/backend/tasks/notifications/handlers.py +++ b/backend/tasks/notifications/handlers.py @@ -264,7 +264,13 @@ def notify_task_updated(task: Task, actor, previous: dict, current: dict): generic_fields = [ field - for field in ("title", "description", "priority", "label_ids") + for field in ( + "title", + "description", + "priority", + "label_ids", + "cover_attachment_id", + ) if previous.get(field) != current.get(field) ] if participant_ids_changed: diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index a7cb0c8d..43eb9a7b 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -1498,6 +1498,107 @@ def test_task_attachment_has_no_size_limit( assert TaskAttachment.objects.filter(task=task, file_name="large.txt").exists() +def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared( + api_client, + user, + tmp_path, + settings, +): + settings.MEDIA_ROOT = tmp_path + member = make_user("task-cover-member@example.com", "+79994440105") + outsider = make_user("task-cover-outsider@example.com", "+79994440106") + board = TaskBoard.objects.create(name="Доска с обложкой", created_by=user) + board.members.add(member) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + task = Task.objects.create( + board=board, + column=column, + title="Задача с обложкой", + created_by=user, + ) + first_image = TaskAttachment.objects.create( + task=task, + file=SimpleUploadedFile("first.png", b"first image", content_type="image/png"), + file_name="first.png", + file_size=11, + mime_type="image/png", + uploaded_by=user, + ) + second_image = TaskAttachment.objects.create( + task=task, + file=SimpleUploadedFile("second.jpg", b"second image", content_type="image/jpeg"), + file_name="second.jpg", + file_size=12, + mime_type="image/jpeg", + uploaded_by=user, + ) + document = TaskAttachment.objects.create( + task=task, + file=SimpleUploadedFile("brief.pdf", b"pdf", content_type="application/pdf"), + file_name="brief.pdf", + file_size=3, + mime_type="application/pdf", + uploaded_by=user, + ) + cover_url = reverse("api:v1:tasks:task-cover", kwargs={"pk": task.id}) + + api_client.force_authenticate(user=member) + first_response = api_client.post( + cover_url, + {"attachment_id": first_image.id}, + format="json", + ) + assert first_response.status_code == status.HTTP_200_OK + assert first_response.data["cover_attachment"]["id"] == first_image.id + assert first_response.data["cover_attachment"]["is_cover"] is True + + second_response = api_client.post( + cover_url, + {"attachment_id": second_image.id}, + format="json", + ) + assert second_response.status_code == status.HTTP_200_OK + first_image.refresh_from_db() + second_image.refresh_from_db() + assert first_image.is_cover is False + assert second_image.is_cover is True + assert TaskAttachment.objects.filter(task=task, is_cover=True).count() == 1 + board_response = api_client.get( + reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}) + ) + assert board_response.status_code == status.HTTP_200_OK + serialized_task = next( + item for item in board_response.data["tasks"] if item["id"] == task.id + ) + assert serialized_task["cover_attachment"]["id"] == second_image.id + + invalid_response = api_client.post( + cover_url, + {"attachment_id": document.id}, + format="json", + ) + assert invalid_response.status_code == status.HTTP_400_BAD_REQUEST + second_image.refresh_from_db() + assert second_image.is_cover is True + + api_client.force_authenticate(user=outsider) + assert ( + api_client.post( + cover_url, + {"attachment_id": first_image.id}, + format="json", + ).status_code + == status.HTTP_404_NOT_FOUND + ) + + api_client.force_authenticate(user=member) + clear_response = api_client.delete(cover_url) + assert clear_response.status_code == status.HTTP_200_OK + assert clear_response.data["cover_attachment"] is None + assert TaskAttachment.objects.filter(task=task).count() == 3 + assert not TaskAttachment.objects.filter(task=task, is_cover=True).exists() + + def test_task_external_links_follow_board_access_and_support_lifecycle( api_client, user, diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index b8ea9c0b..84aa09e4 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -74,6 +74,7 @@ import { import { AppShell } from "@/components/AppShell"; import TaskBoardAvatar from "@/components/tasks/TaskBoardAvatar"; import TaskBoardNavigator from "@/components/tasks/TaskBoardNavigator"; +import TaskCoverImage from "@/components/tasks/TaskCoverImage"; import { RequestAvatar } from "@/components/requests/RequestAvatar"; import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls"; import { Modal } from "@/components/ui"; @@ -417,6 +418,16 @@ function formatFileSize(size: number): string { })} МБ`; } +function isImageAttachment(attachment: TaskAttachment): boolean { + if (attachment.mime_type?.toLowerCase().startsWith("image/")) return true; + return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(attachment.file_name); +} + +function isImageFile(file: File): boolean { + if (file.type.toLowerCase().startsWith("image/")) return true; + return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(file.name); +} + function getMessageCreatedLabel(message?: Message | null) { if (!message) return ""; return message.created || (message.created_at ? formatDate(message.created_at) : ""); @@ -471,6 +482,13 @@ function TaskCardView({ {...attributes} {...listeners} > + {task.cover_attachment ? ( + + ) : null}
@@ -2410,12 +2428,18 @@ function TaskFormResourceRow({ title, meta, pending = false, + canUseAsCover = false, + isCover = false, + onToggleCover, onRemove, }: { icon: LucideIcon; title: string; meta?: string; pending?: boolean; + canUseAsCover?: boolean; + isCover?: boolean; + onToggleCover?: () => void; onRemove: () => void; }) { return ( @@ -2438,6 +2462,18 @@ function TaskFormResourceRow({ новое ) : null} + {canUseAsCover && onToggleCover ? ( + + ) : null} + {isImageAttachment(attachment) ? ( + + ) : null}
diff --git a/frontend/src/components/tasks/TaskCoverImage.tsx b/frontend/src/components/tasks/TaskCoverImage.tsx new file mode 100644 index 00000000..aa381573 --- /dev/null +++ b/frontend/src/components/tasks/TaskCoverImage.tsx @@ -0,0 +1,77 @@ +"use client"; + +import Image from "next/image"; +import { ImagePlus, Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { apiClient } from "@/lib/api"; +import type { TaskAttachment } from "@/types/api"; + +export default function TaskCoverImage({ + taskId, + attachment, + className = "", +}: { + taskId: number; + attachment: TaskAttachment; + className?: string; +}) { + const [loadedImage, setLoadedImage] = useState<{ + attachmentId: number; + source: string; + } | null>(null); + const [failedAttachmentId, setFailedAttachmentId] = useState(null); + const attachmentId = attachment.id; + const downloadUrl = attachment.download_url; + const fileName = attachment.file_name; + + useEffect(() => { + let active = true; + let objectUrl: string | null = null; + + void apiClient + .downloadTaskAttachment(taskId, { + id: attachmentId, + download_url: downloadUrl, + file_name: fileName, + }) + .then(({ blob }) => { + if (!active) return; + objectUrl = URL.createObjectURL(blob); + setLoadedImage({ attachmentId, source: objectUrl }); + }) + .catch(() => { + if (active) setFailedAttachmentId(attachmentId); + }); + + return () => { + active = false; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [attachmentId, downloadUrl, fileName, taskId]); + + const source = loadedImage?.attachmentId === attachment.id + ? loadedImage.source + : null; + const failed = failedAttachmentId === attachment.id; + + return ( +
+ {source ? ( + + ) : ( + + {failed ? : } + + )} +
+ ); +} diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts index 52514c03..760022cc 100644 --- a/frontend/src/lib/api/tasks.ts +++ b/frontend/src/lib/api/tasks.ts @@ -211,7 +211,7 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) { }, downloadTaskAttachment: async ( taskId: number, - attachment: TaskAttachment, + attachment: Pick, ): Promise<{ blob: Blob; filename: string }> => { const response = await requestRaw( attachment.download_url || `/api/v1/tasks/${taskId}/attachments/${attachment.id}/download/`, @@ -225,6 +225,15 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) { request(`/api/v1/tasks/${taskId}/attachments/${attachmentId}/`, { method: "DELETE", }), + setTaskCover: (taskId: number, attachmentId: number): Promise => + request(`/api/v1/tasks/${taskId}/cover/`, { + method: "POST", + body: JSON.stringify({ attachment_id: attachmentId }), + }), + clearTaskCover: (taskId: number): Promise => + request(`/api/v1/tasks/${taskId}/cover/`, { + method: "DELETE", + }), getTaskChecklist: (id: number): Promise => request(`/api/v1/tasks/${id}/checklist/`), addTaskChecklistItem: ( diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 798c7b9a..a088bf10 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -240,6 +240,7 @@ export interface TaskCard { linked_objects_count?: number; comments_count?: number; attachments_count?: number; + cover_attachment?: TaskAttachment | null; checklist_total?: number; checklist_completed?: number; created_at: string; @@ -540,6 +541,7 @@ export interface TaskAttachment { file_name: string; file_size: number; mime_type?: string; + is_cover: boolean; uploaded_by?: User; created_at: string; download_url: string;