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 ? (
+