diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 5cb780d0..a279ead2 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -55,6 +55,8 @@ from tasks.models import ( TaskChecklistItem, TaskColumn, TaskColumnRow, + TaskCover, + TaskCoverType, TaskExternalLink, TaskLabel, TaskLinkedObject, @@ -434,6 +436,7 @@ class TaskSerializer(serializers.ModelSerializer): linked_objects_count = serializers.SerializerMethodField() comments_count = serializers.SerializerMethodField() attachments_count = serializers.SerializerMethodField() + cover = serializers.SerializerMethodField() cover_attachment = serializers.SerializerMethodField() checklist_total = serializers.SerializerMethodField() checklist_completed = serializers.SerializerMethodField() @@ -477,6 +480,7 @@ class TaskSerializer(serializers.ModelSerializer): "linked_objects_count", "comments_count", "attachments_count", + "cover", "cover_attachment", "checklist_total", "checklist_completed", @@ -507,6 +511,7 @@ class TaskSerializer(serializers.ModelSerializer): "linked_objects_count", "comments_count", "attachments_count", + "cover", "cover_attachment", "checklist_total", "checklist_completed", @@ -612,28 +617,29 @@ class TaskSerializer(serializers.ModelSerializer): return obj.attachments_count return obj.attachments.count() + def _get_cover_payload(self, obj): + if hasattr(obj, "_task_cover_payload"): + return obj._task_cover_payload + try: + cover = obj.cover + except TaskCover.DoesNotExist: + cover = None + payload = ( + TaskCoverSerializer(cover, context=self.context).data + if cover is not None + else None + ) + obj._task_cover_payload = payload + return payload + + @extend_schema_field(serializers.DictField(allow_null=True)) + def get_cover(self, obj): + return self._get_cover_payload(obj) + @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 + payload = self._get_cover_payload(obj) + return payload.get("attachment") if payload else None @extend_schema_field(serializers.IntegerField()) def get_checklist_total(self, obj): @@ -1157,7 +1163,6 @@ class TaskAttachmentSerializer(serializers.ModelSerializer): "file_name", "file_size", "mime_type", - "is_cover", "uploaded_by", "created_at", "download_url", @@ -1845,3 +1850,149 @@ class TaskLinkedAttendanceRecordSerializer(serializers.ModelSerializer): def get_attendance_record_content_type(): return ContentType.objects.get_for_model(AttendanceRecord) + + +def _linked_object_cover_payload(link, context): + source = getattr(link, "content_object", None) + kind = link.kind + title = link.get_kind_display() + description = "" + image_url = None + + if source is not None: + if kind == TaskLinkedObjectKind.POST: + title = source.title or "Новость" + description = source.body or "" + if source.image: + from api.v1.utils import build_media_url + + 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 "Сообщение" + ) + description = source.content or "" + elif kind == TaskLinkedObjectKind.CALENDAR_EVENT: + title = getattr(source, "title", None) or "Календарное событие" + description = getattr(source, "description", "") or "" + elif kind == TaskLinkedObjectKind.DOCUMENT: + title = source.title or "Документ" + description = source.description or "" + elif kind == TaskLinkedObjectKind.REQUEST: + title = source.display_title or source.title or "Заявление" + description = source.comment or "" + elif kind == TaskLinkedObjectKind.PROCUREMENT_REQUEST: + title = source.title or "Заявка на закупку" + description = source.description or "" + elif kind == TaskLinkedObjectKind.EMPLOYEE: + title = source.get_full_name() or source.email or "Сотрудник" + description = getattr(source, "position_name", "") or "" + elif kind == TaskLinkedObjectKind.GUEST: + title = source.full_name or "Гость" + description = source.organization or "" + elif kind == TaskLinkedObjectKind.GUEST_VISIT: + title = source.guest.full_name or "Гостевой визит" + description = source.purpose or "" + elif kind == TaskLinkedObjectKind.ATTENDANCE_RECORD: + title = source.display_name or "Запись посещаемости" + description = str(source.date) + + return { + "id": link.id, + "kind": kind, + "kind_display": link.get_kind_display(), + "object_id": link.object_id, + "title": str(title), + "description": str(description), + "image_url": image_url, + } + + +class TaskCoverSerializer(serializers.ModelSerializer): + kind_display = serializers.CharField(source="get_kind_display", read_only=True) + attachment = serializers.SerializerMethodField() + checklist = serializers.SerializerMethodField() + comment = serializers.SerializerMethodField() + external_link = serializers.SerializerMethodField() + linked_object = serializers.SerializerMethodField() + + class Meta: + model = TaskCover + fields = [ + "id", + "kind", + "kind_display", + "attachment", + "checklist", + "comment", + "external_link", + "linked_object", + "updated_at", + ] + read_only_fields = fields + + @extend_schema_field(serializers.DictField(allow_null=True)) + def get_attachment(self, obj): + if obj.kind != TaskCoverType.ATTACHMENT or obj.attachment is None: + return None + return TaskAttachmentSerializer( + obj.attachment, + context=self.context, + ).data + + @extend_schema_field(serializers.DictField(allow_null=True)) + def get_checklist(self, obj): + if obj.kind != TaskCoverType.CHECKLIST: + return None + items = list(obj.task.checklist_items.all()) + return { + "total": len(items), + "completed": sum(item.is_completed for item in items), + "items": [ + { + "id": item.id, + "title": item.title, + "is_completed": item.is_completed, + } + for item in items[:6] + ], + } + + @extend_schema_field(serializers.DictField(allow_null=True)) + def get_comment(self, obj): + message = obj.comment + if obj.kind != TaskCoverType.COMMENT or message is None or message.is_deleted: + return None + attachments = [ + attachment + for attachment in message.task_attachments.all() + if attachment.task_id == obj.task_id + ] + return { + "id": message.id, + "author": EmployeeBriefSerializer(message.author).data, + "text": message.content, + "created_at": message.created_at, + "attachments": TaskAttachmentSerializer( + attachments, + many=True, + context=self.context, + ).data, + } + + @extend_schema_field(serializers.DictField(allow_null=True)) + def get_external_link(self, obj): + if obj.kind != TaskCoverType.EXTERNAL_LINK or obj.external_link is None: + return None + return TaskExternalLinkSerializer( + obj.external_link, + context=self.context, + ).data + + @extend_schema_field(serializers.DictField(allow_null=True)) + def get_linked_object(self, obj): + if obj.kind != TaskCoverType.LINKED_OBJECT or obj.linked_object is None: + return None + return _linked_object_cover_payload(obj.linked_object, self.context) diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index d5d0e0c0..dd7f783a 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -66,6 +66,8 @@ from tasks.models import ( TaskBoardPlacement, TaskColumn, TaskColumnRow, + TaskCover, + TaskCoverType, TaskExternalLink, TaskLabel, TaskLinkedObject, @@ -369,14 +371,37 @@ class TaskBoardViewSet(viewsets.ModelViewSet): ), ).order_by("parent_id", "position", "id"), ), - "tasks__created_by", - "tasks__assignee", - "tasks__participants", - "tasks__labels", - "tasks__linked_objects", - "tasks__external_links", - "tasks__attachments", - "tasks__checklist_items", + Prefetch( + "tasks", + queryset=Task.objects.select_related( + "created_by", + "assignee", + "column", + "row", + "cover", + "cover__attachment", + "cover__attachment__uploaded_by", + "cover__comment", + "cover__comment__author", + "cover__external_link", + "cover__external_link__created_by", + "cover__linked_object", + "cover__linked_object__content_type", + ).prefetch_related( + "participants", + "labels", + "linked_objects", + "external_links", + "attachments", + "checklist_items", + Prefetch( + "cover__comment__task_attachments", + queryset=TaskAttachment.objects.select_related( + "uploaded_by" + ), + ), + ), + ), ) return queryset @@ -1041,17 +1066,29 @@ class TaskViewSet(viewsets.ModelViewSet): task_board_access_q(self.request.user) ) ) - .select_related("board", "column", "row", "created_by", "assignee") + .select_related( + "board", + "column", + "row", + "created_by", + "assignee", + "cover", + "cover__attachment", + "cover__attachment__uploaded_by", + "cover__comment", + "cover__comment__author", + "cover__external_link", + "cover__external_link__created_by", + "cover__linked_object", + "cover__linked_object__content_type", + ) .prefetch_related( "labels", "participants", "checklist_items", Prefetch( - "attachments", - queryset=TaskAttachment.objects.filter( - is_cover=True - ).select_related("uploaded_by"), - to_attr="cover_attachments", + "cover__comment__task_attachments", + queryset=TaskAttachment.objects.select_related("uploaded_by"), ), ) .annotate( @@ -1544,7 +1581,11 @@ class TaskViewSet(viewsets.ModelViewSet): ) attachment_id = attachment.id file_name = attachment.file_name - was_cover = attachment.is_cover + was_cover = TaskCover.objects.filter( + task=task, + kind=TaskCoverType.ATTACHMENT, + attachment=attachment, + ).exists() attachment.delete() create_task_activity( task, @@ -1560,7 +1601,7 @@ class TaskViewSet(viewsets.ModelViewSet): task.id, extra={ "attachments_count": task.attachments.count(), - **({"cover_attachment_id": None} if was_cover else {}), + **({"cover": None} if was_cover else {}), }, ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -1568,66 +1609,140 @@ class TaskViewSet(viewsets.ModelViewSet): @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() + previous_cover = TaskCover.objects.filter(task=locked_task).first() + previous = ( + { + "kind": previous_cover.kind, + "source_id": ( + previous_cover.attachment_id + or previous_cover.comment_id + or previous_cover.external_link_id + or previous_cover.linked_object_id + ), + } + if previous_cover + else None ) - 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/"): + + if request.method == "DELETE": + TaskCover.objects.filter(task=locked_task).delete() + next_cover = None + else: + kind = request.data.get("kind") + attachment_id = request.data.get("attachment_id") + if not kind and attachment_id: + kind = TaskCoverType.ATTACHMENT + if kind not in TaskCoverType.values: return Response( - {"attachment_id": ["Обложкой может быть только изображение."]}, + {"kind": ["Выберите допустимый тип обложки."]}, 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 + source_id = request.data.get("source_id") + if ( + kind != TaskCoverType.CHECKLIST + and not (attachment_id or source_id) + ): + return Response( + {"source_id": ["Укажите источник обложки."]}, + status=status.HTTP_400_BAD_REQUEST, ) + defaults = { + "kind": kind, + "attachment": None, + "comment": None, + "external_link": None, + "linked_object": None, + "created_by": request.user, + } + if kind == TaskCoverType.ATTACHMENT: + source_id = attachment_id or source_id + attachment = get_object_or_404( + locked_task.attachments, + id=source_id, + ) + content_type = attachment.mime_type or ( + mimetypes.guess_type(attachment.file_name)[0] or "" + ) + if not content_type.lower().startswith(("image/", "video/")): + return Response( + { + "source_id": [ + "Обложкой может быть изображение или видео." + ] + }, + status=status.HTTP_400_BAD_REQUEST, + ) + defaults["attachment"] = attachment + elif kind == TaskCoverType.CHECKLIST: + if not locked_task.checklist_items.exists(): + return Response( + {"kind": ["В задаче пока нет пунктов чек-листа."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + elif kind == TaskCoverType.COMMENT: + comment = get_object_or_404( + comments_helpers.get_comments(locked_task), + id=source_id, + ) + defaults["comment"] = comment + elif kind == TaskCoverType.EXTERNAL_LINK: + defaults["external_link"] = get_object_or_404( + locked_task.external_links, + id=source_id, + ) + elif kind == TaskCoverType.LINKED_OBJECT: + defaults["linked_object"] = get_object_or_404( + locked_task.linked_objects, + id=source_id, + ) + + next_cover, _ = TaskCover.objects.update_or_create( + task=locked_task, + defaults=defaults, + ) + next_cover.full_clean() + + current = ( + { + "kind": next_cover.kind, + "source_id": ( + next_cover.attachment_id + or next_cover.comment_id + or next_cover.external_link_id + or next_cover.linked_object_id + ), + } + if next_cover + else None + ) + if previous != current: locked_task.save(update_fields=["updated_at"]) - if previous_id != next_id: + if previous != current: create_task_activity( locked_task, request.user, TaskActivityAction.UPDATED, metadata={ - "fields": ["cover_attachment_id"], - "cover_attachment_id": next_id, + "fields": ["cover"], + "cover": current, }, ) notify_task_updated( locked_task, request.user, - {"cover_attachment_id": previous_id}, - {"cover_attachment_id": next_id}, + {"cover": previous}, + {"cover": current}, ) send_task_board_update( locked_task.board, "updated", "task", locked_task.id, - extra={"cover_attachment_id": next_id}, + extra={"cover": current}, ) refreshed_task = self.get_queryset().get(pk=locked_task.pk) @@ -2071,6 +2186,11 @@ class TaskViewSet(viewsets.ModelViewSet): ) comment_text = message.content + TaskCover.objects.filter( + task=task, + kind=TaskCoverType.COMMENT, + comment=message, + ).delete() task.attachments.filter(comment=message).update(comment=None) comments_helpers.delete_comment( message=message, diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index 4991afe4..1a2ce054 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -12,6 +12,7 @@ from .models import ( TaskChecklistItem, TaskColumn, TaskColumnRow, + TaskCover, TaskExternalLink, TaskLabel, TaskLinkedObject, @@ -151,16 +152,30 @@ class TaskAttachmentAdmin(admin.ModelAdmin): list_display = [ "file_name", "task", - "is_cover", "uploaded_by", "file_size", "created_at", ] - list_filter = ["is_cover", "created_at"] + list_filter = ["created_at"] search_fields = ["file_name", "task__title", "uploaded_by__email"] autocomplete_fields = ["task", "uploaded_by"] +@admin.register(TaskCover) +class TaskCoverAdmin(admin.ModelAdmin): + list_display = ["task", "kind", "created_by", "updated_at"] + list_filter = ["kind", "updated_at"] + search_fields = ["task__title", "created_by__email"] + autocomplete_fields = [ + "task", + "attachment", + "comment", + "external_link", + "linked_object", + "created_by", + ] + + @admin.register(TaskChecklistItem) class TaskChecklistItemAdmin(admin.ModelAdmin): list_display = [ diff --git a/backend/tasks/migrations/0033_universal_task_covers.py b/backend/tasks/migrations/0033_universal_task_covers.py new file mode 100644 index 00000000..513baa66 --- /dev/null +++ b/backend/tasks/migrations/0033_universal_task_covers.py @@ -0,0 +1,104 @@ +# Generated by Django 5.2.4 on 2026-08-06 09:58 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def migrate_attachment_covers(apps, schema_editor): + TaskAttachment = apps.get_model("tasks", "TaskAttachment") + TaskCover = apps.get_model("tasks", "TaskCover") + covers = TaskAttachment.objects.filter(is_cover=True).values( + "id", + "task_id", + "uploaded_by_id", + ) + TaskCover.objects.bulk_create( + [ + TaskCover( + task_id=cover["task_id"], + kind="attachment", + attachment_id=cover["id"], + created_by_id=cover["uploaded_by_id"], + ) + for cover in covers + ], + ignore_conflicts=True, + ) + + +def restore_attachment_covers(apps, schema_editor): + TaskAttachment = apps.get_model("tasks", "TaskAttachment") + TaskCover = apps.get_model("tasks", "TaskCover") + attachment_ids = TaskCover.objects.filter( + kind="attachment", + attachment_id__isnull=False, + ).values_list("attachment_id", flat=True) + TaskAttachment.objects.filter(id__in=attachment_ids).update(is_cover=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('communications', '0039_alter_chat_context_object_id_bigint'), + ('tasks', '0032_taskattachment_comment'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TaskCover', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('kind', models.CharField(choices=[('attachment', 'Файл'), ('checklist', 'Чек-лист'), ('comment', 'Комментарий'), ('external_link', 'Внешняя ссылка'), ('linked_object', 'Связанный объект')], max_length=32, verbose_name='Тип')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), + ], + options={ + 'verbose_name': 'Обложка задачи', + 'verbose_name_plural': 'Обложки задач', + }, + ), + migrations.AddField( + model_name='taskcover', + name='attachment', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='task_covers', to='tasks.taskattachment', verbose_name='Файл'), + ), + migrations.AddField( + model_name='taskcover', + name='comment', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='task_covers', to='communications.message', verbose_name='Комментарий'), + ), + migrations.AddField( + model_name='taskcover', + name='created_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_task_covers', to=settings.AUTH_USER_MODEL, verbose_name='Установил'), + ), + migrations.AddField( + model_name='taskcover', + name='external_link', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='task_covers', to='tasks.taskexternallink', verbose_name='Внешняя ссылка'), + ), + migrations.AddField( + model_name='taskcover', + name='linked_object', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='task_covers', to='tasks.tasklinkedobject', verbose_name='Связанный объект'), + ), + migrations.AddField( + model_name='taskcover', + name='task', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='cover', to='tasks.task', verbose_name='Задача'), + ), + migrations.RunPython( + migrate_attachment_covers, + restore_attachment_covers, + ), + migrations.RemoveConstraint( + model_name='taskattachment', + name='uniq_task_cover_attachment', + ), + migrations.RemoveField( + model_name='taskattachment', + name='is_cover', + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index d8677ac1..f020f33f 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -1,6 +1,7 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db import models from django.db.models.functions import Lower from django.utils import timezone @@ -14,6 +15,14 @@ class TaskPriority(models.TextChoices): CRITICAL = "critical", "Критический" +class TaskCoverType(models.TextChoices): + ATTACHMENT = "attachment", "Файл" + CHECKLIST = "checklist", "Чек-лист" + COMMENT = "comment", "Комментарий" + EXTERNAL_LINK = "external_link", "Внешняя ссылка" + LINKED_OBJECT = "linked_object", "Связанный объект" + + class TaskAutomationKind(models.TextChoices): EVENT = "event", "Событие" SCHEDULE = "schedule", "Срок" @@ -545,7 +554,6 @@ 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, @@ -559,13 +567,6 @@ 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}" @@ -786,3 +787,113 @@ class TaskExternalLink(models.Model): def __str__(self): return f"{self.task} -> {self.title or self.url}" + + +class TaskCover(models.Model): + task = models.OneToOneField( + Task, + on_delete=models.CASCADE, + related_name="cover", + verbose_name="Задача", + ) + kind = models.CharField( + "Тип", + max_length=32, + choices=TaskCoverType.choices, + ) + attachment = models.ForeignKey( + TaskAttachment, + on_delete=models.CASCADE, + related_name="task_covers", + null=True, + blank=True, + verbose_name="Файл", + ) + comment = models.ForeignKey( + "communications.Message", + on_delete=models.CASCADE, + related_name="task_covers", + null=True, + blank=True, + verbose_name="Комментарий", + ) + external_link = models.ForeignKey( + TaskExternalLink, + on_delete=models.CASCADE, + related_name="task_covers", + null=True, + blank=True, + verbose_name="Внешняя ссылка", + ) + linked_object = models.ForeignKey( + TaskLinkedObject, + on_delete=models.CASCADE, + related_name="task_covers", + null=True, + blank=True, + verbose_name="Связанный объект", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="created_task_covers", + verbose_name="Установил", + ) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Обложка задачи" + verbose_name_plural = "Обложки задач" + + def __str__(self): + return f"{self.task}: {self.get_kind_display()}" + + def clean(self): + super().clean() + sources = { + TaskCoverType.ATTACHMENT: self.attachment_id, + TaskCoverType.CHECKLIST: True, + TaskCoverType.COMMENT: self.comment_id, + TaskCoverType.EXTERNAL_LINK: self.external_link_id, + TaskCoverType.LINKED_OBJECT: self.linked_object_id, + } + selected_source = sources.get(self.kind) + if not selected_source: + raise ValidationError("Для выбранного типа укажите источник обложки.") + + populated = sum( + value is not None + for value in ( + self.attachment_id, + self.comment_id, + self.external_link_id, + self.linked_object_id, + ) + ) + expected = 0 if self.kind == TaskCoverType.CHECKLIST else 1 + if populated != expected: + raise ValidationError("Обложка должна содержать только один источник.") + + task_bound_sources = ( + self.attachment, + self.external_link, + self.linked_object, + ) + if any( + source is not None and source.task_id != self.task_id + for source in task_bound_sources + ): + raise ValidationError("Источник обложки должен принадлежать этой задаче.") + + if self.comment_id: + task_content_type = ContentType.objects.get_for_model(Task) + chat = self.comment.chat + if not ( + chat.type == "comments" + and chat.context_content_type_id == task_content_type.id + and chat.context_object_id == self.task_id + ): + raise ValidationError( + "Комментарий обложки должен принадлежать этой задаче." + ) diff --git a/backend/tasks/notifications/config.py b/backend/tasks/notifications/config.py index 96bbe657..c894b175 100644 --- a/backend/tasks/notifications/config.py +++ b/backend/tasks/notifications/config.py @@ -124,6 +124,7 @@ class MessageTemplates: "label_ids": "метки", "participant_ids": "участников", "cover_attachment_id": "обложку", + "cover": "обложку", } 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 5386dca0..bcb0ec95 100644 --- a/backend/tasks/notifications/handlers.py +++ b/backend/tasks/notifications/handlers.py @@ -270,6 +270,7 @@ def notify_task_updated(task: Task, actor, previous: dict, current: dict): "priority", "label_ids", "cover_attachment_id", + "cover", ) if previous.get(field) != current.get(field) ] diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 26e1d353..d03dffeb 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -32,8 +32,12 @@ from tasks.models import ( TaskChecklistItem, TaskColumn, TaskColumnRow, + TaskCover, + TaskCoverType, TaskExternalLink, TaskLabel, + TaskLinkedObject, + TaskLinkedObjectKind, TaskUserSettings, ) from tasks.realtime import get_task_board_recipient_ids @@ -1606,6 +1610,14 @@ def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared( mime_type="application/pdf", uploaded_by=user, ) + video = TaskAttachment.objects.create( + task=task, + file=SimpleUploadedFile("demo.mp4", b"video", content_type="video/mp4"), + file_name="demo.mp4", + file_size=5, + mime_type="video/mp4", + uploaded_by=user, + ) cover_url = reverse("api:v1:tasks:task-cover", kwargs={"pk": task.id}) api_client.force_authenticate(user=member) @@ -1616,7 +1628,7 @@ def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared( ) 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 + assert first_response.data["cover"]["kind"] == TaskCoverType.ATTACHMENT second_response = api_client.post( cover_url, @@ -1624,11 +1636,7 @@ def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared( 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 + assert TaskCover.objects.get(task=task).attachment == second_image board_response = api_client.get( reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}) ) @@ -1638,14 +1646,21 @@ def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared( ) assert serialized_task["cover_attachment"]["id"] == second_image.id + video_response = api_client.post( + cover_url, + {"kind": TaskCoverType.ATTACHMENT, "source_id": video.id}, + format="json", + ) + assert video_response.status_code == status.HTTP_200_OK + assert video_response.data["cover"]["attachment"]["mime_type"] == "video/mp4" + 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 + assert TaskCover.objects.get(task=task).attachment == video api_client.force_authenticate(user=outsider) assert ( @@ -1661,8 +1676,117 @@ def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared( 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() + assert clear_response.data["cover"] is None + assert TaskAttachment.objects.filter(task=task).count() == 4 + assert not TaskCover.objects.filter(task=task).exists() + + +def test_task_cover_supports_checklist_comment_link_and_linked_object( + api_client, + user, +): + api_client.force_authenticate(user=user) + board = TaskBoard.objects.create(name="Универсальные обложки", created_by=user) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + task = Task.objects.create( + board=board, + column=column, + title="Источники обложки", + created_by=user, + ) + checklist_item = TaskChecklistItem.objects.create( + task=task, + title="Проверить результат", + position=1000, + created_by=user, + ) + link = TaskExternalLink.objects.create( + task=task, + url="https://example.com/preview", + title="Пример ссылки", + created_by=user, + ) + document = Document.objects.create( + title="Связанный документ", + description="Описание документа", + uploaded_by=user, + ) + linked_object = TaskLinkedObject.objects.create( + task=task, + kind=TaskLinkedObjectKind.DOCUMENT, + content_type=ContentType.objects.get_for_model(Document), + object_id=document.id, + created_by=user, + ) + cover_url = reverse("api:v1:tasks:task-cover", kwargs={"pk": task.id}) + + checklist_response = api_client.post( + cover_url, + {"kind": TaskCoverType.CHECKLIST}, + format="json", + ) + assert checklist_response.status_code == status.HTTP_200_OK + assert checklist_response.data["cover"]["checklist"] == { + "total": 1, + "completed": 0, + "items": [ + { + "id": checklist_item.id, + "title": checklist_item.title, + "is_completed": False, + } + ], + } + + comment_response = api_client.post( + reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}), + {"text": "Комментарий на обложке"}, + format="json", + ) + assert comment_response.status_code == status.HTTP_201_CREATED + comment_cover_response = api_client.post( + cover_url, + { + "kind": TaskCoverType.COMMENT, + "source_id": comment_response.data["id"], + }, + format="json", + ) + assert comment_cover_response.status_code == status.HTTP_200_OK + assert comment_cover_response.data["cover"]["comment"]["text"] == ( + "Комментарий на обложке" + ) + delete_comment_response = api_client.delete( + reverse( + "api:v1:tasks:task-delete-comment", + kwargs={"pk": task.id, "comment_id": comment_response.data["id"]}, + ) + ) + assert delete_comment_response.status_code == status.HTTP_204_NO_CONTENT + assert not TaskCover.objects.filter(task=task).exists() + + link_response = api_client.post( + cover_url, + {"kind": TaskCoverType.EXTERNAL_LINK, "source_id": link.id}, + format="json", + ) + assert link_response.status_code == status.HTTP_200_OK + assert link_response.data["cover"]["external_link"]["title"] == ( + "Пример ссылки" + ) + + object_response = api_client.post( + cover_url, + { + "kind": TaskCoverType.LINKED_OBJECT, + "source_id": linked_object.id, + }, + format="json", + ) + assert object_response.status_code == status.HTTP_200_OK + assert object_response.data["cover"]["linked_object"]["title"] == ( + "Связанный документ" + ) def test_task_external_links_follow_board_access_and_support_lifecycle( diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 3a50ba26..fe204022 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -75,7 +75,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 TaskCoverPreview from "@/components/tasks/TaskCoverPreview"; import TaskCommentAttachment from "@/components/tasks/TaskCommentAttachment"; import TaskAutomationManager from "@/components/tasks/TaskAutomationManager"; import { RequestAvatar } from "@/components/requests/RequestAvatar"; @@ -104,6 +104,7 @@ import type { TaskColumn, TaskColumnRow, TaskComment, + TaskCoverType, TaskExternalLink, TaskLinkedAttendanceRecord, TaskLinkedDocument, @@ -170,6 +171,14 @@ type LinkedItemMode = "document" | "calendar_event" | "external_link"; type LinkedItemTarget = "view" | "form"; type TaskAddPanel = "due_date" | "labels" | null; +type TaskCoverLinkedOption = { + id: number; + kind: string; + kindLabel: string; + title: string; + description: string; +}; + type TaskFormExternalLinkDraft = { key: string; url: string; @@ -434,11 +443,29 @@ function isImageAttachment(attachment: TaskAttachment): boolean { return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(attachment.file_name); } +function isVideoAttachment(attachment: TaskAttachment): boolean { + if (attachment.mime_type?.toLowerCase().startsWith("video/")) return true; + return /\.(m4v|mkv|mov|mp4|mpeg|mpg|ogv|webm)$/i.test(attachment.file_name); +} + +function isMediaAttachment(attachment: TaskAttachment): boolean { + return isImageAttachment(attachment) || isVideoAttachment(attachment); +} + 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 isVideoFile(file: File): boolean { + if (file.type.toLowerCase().startsWith("video/")) return true; + return /\.(m4v|mkv|mov|mp4|mpeg|mpg|ogv|webm)$/i.test(file.name); +} + +function isMediaFile(file: File): boolean { + return isImageFile(file) || isVideoFile(file); +} + const TASK_COMMENT_URL_PATTERN = /(https?:\/\/[^\s<>"']+)/gi; const TASK_COMMENT_URL_TRAILING_PUNCTUATION = /[.,;:!?)}\]]+$/; @@ -521,10 +548,10 @@ function TaskCardView({ {...attributes} {...listeners} > - {task.cover_attachment ? ( - ) : null} @@ -2594,6 +2621,101 @@ function TaskFormResourceRow({ ); } +function TaskCoverOption({ + icon: Icon, + title, + meta, + selected, + disabled, + onSelect, +}: { + icon: LucideIcon; + title: string; + meta?: string; + selected: boolean; + disabled?: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +function TaskCoverToggleButton({ + isCover, + disabled, + onToggle, + className = "", +}: { + isCover: boolean; + disabled?: boolean; + onToggle: () => void; + className?: string; +}) { + return ( + + ); +} + +function CoverableTaskResource({ + children, + isCover, + disabled, + onToggle, +}: { + children: ReactNode; + isCover: boolean; + disabled?: boolean; + onToggle: () => void; +}) { + return ( +
+ {children} + +
+ ); +} + function TaskChecklistFormRow({ title, @@ -2985,6 +3107,7 @@ function TasksPageContent() { const [taskFormAttachments, setTaskFormAttachments] = useState([]); const [taskFormCoverAttachmentId, setTaskFormCoverAttachmentId] = useState(null); const [taskFormCoverPendingFile, setTaskFormCoverPendingFile] = useState(null); + const [taskFormCoverChanged, setTaskFormCoverChanged] = useState(false); const [taskFormDocuments, setTaskFormDocuments] = useState([]); const [taskFormEvents, setTaskFormEvents] = useState([]); const [taskFormExternalLinks, setTaskFormExternalLinks] = useState([]); @@ -3003,6 +3126,8 @@ function TasksPageContent() { const [taskCommentsLoading, setTaskCommentsLoading] = useState(false); const [commentDraft, setCommentDraft] = useState(""); const [commentFiles, setCommentFiles] = useState([]); + const [coverPickerOpen, setCoverPickerOpen] = useState(false); + const [coverPickerLoading, setCoverPickerLoading] = useState(false); const [attachmentsOpen, setAttachmentsOpen] = useState(false); const [taskAttachments, setTaskAttachments] = useState([]); const [taskAttachmentsLoading, setTaskAttachmentsLoading] = useState(false); @@ -3331,6 +3456,7 @@ function TasksPageContent() { setTaskFormAttachments([]); setTaskFormCoverAttachmentId(null); setTaskFormCoverPendingFile(null); + setTaskFormCoverChanged(false); setTaskFormDocuments([]); setTaskFormEvents([]); setTaskFormExternalLinks([]); @@ -3358,10 +3484,14 @@ function TasksPageContent() { if (requestId !== taskFormResourcesRequestRef.current) return; setTaskFormChecklistItems(checklist); setTaskFormAttachments(attachments); + const currentCover = board?.tasks.find((task) => task.id === taskId)?.cover; setTaskFormCoverAttachmentId( - attachments.find((attachment) => attachment.is_cover)?.id ?? null, + currentCover?.kind === "attachment" + ? currentCover.attachment?.id ?? null + : null, ); setTaskFormCoverPendingFile(null); + setTaskFormCoverChanged(false); setTaskFormDocuments(documents); setTaskFormEvents(events); setTaskFormExternalLinks(externalLinks); @@ -3374,7 +3504,7 @@ function TasksPageContent() { setTaskFormResourcesLoading(false); } } - }, []); + }, [board?.tasks]); const loadTaskComments = useCallback(async (taskId: number) => { setTaskCommentsLoading(true); @@ -4005,6 +4135,89 @@ function TasksPageContent() { )); }, [participantModalSearch, taskParticipantCandidates]); const linkedObjectsCount = linkedPosts.length + linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length + linkedGuests.length + linkedGuestVisits.length + linkedAttendanceRecords.length + taskExternalLinks.length; + const taskCoverLinkedOptions = useMemo(() => [ + ...linkedPosts.map((link) => ({ + id: link.id, + kind: link.kind, + kindLabel: "Новость", + title: link.post?.title || "Новость", + description: link.post?.body || "", + })), + ...linkedMessages.map((link) => ({ + id: link.id, + kind: link.kind, + kindLabel: "Сообщение", + title: link.message?.author ? displayUserName(link.message.author) : "Сообщение", + description: link.message?.content || "", + })), + ...linkedEvents.map((link) => ({ + id: link.id, + kind: link.kind, + kindLabel: "Календарное событие", + title: link.event?.title || "Календарное событие", + description: link.event?.description || "", + })), + ...linkedDocuments.map((link) => ({ + id: link.id, + kind: link.kind, + kindLabel: "Документ", + title: link.document?.title || "Документ", + description: link.document?.description || "", + })), + ...linkedRequests.map((link) => ({ + id: link.id, + kind: link.kind, + kindLabel: "Заявление", + title: link.request?.display_title || link.request?.title || "Заявление", + description: link.request?.comment || "", + })), + ...linkedProcurementRequests.map((link) => ({ + id: link.id, + kind: link.kind, + kindLabel: "Заявка на закупку", + title: link.procurement_request?.title || "Заявка на закупку", + description: link.procurement_request?.description || "", + })), + ...linkedEmployees.map((link) => ({ + id: link.id, + kind: link.kind, + kindLabel: "Сотрудник", + title: link.employee?.display_name || link.employee?.full_name || "Сотрудник", + description: link.employee?.position?.name || "", + })), + ...linkedGuests.map((link) => ({ + id: link.id, + kind: link.kind, + kindLabel: "Гость", + title: link.guest?.full_name || "Гость", + description: link.guest?.organization || "", + })), + ...linkedGuestVisits.map((link) => ({ + id: link.id, + kind: link.kind, + kindLabel: "Гостевой визит", + title: link.guest_visit?.guest?.full_name || "Гостевой визит", + description: link.guest_visit?.purpose || "", + })), + ...linkedAttendanceRecords.map((link) => ({ + id: link.id, + kind: link.kind, + kindLabel: "Запись посещаемости", + title: link.attendance_record?.display_name || "Запись посещаемости", + description: link.attendance_record?.date || "", + })), + ], [ + linkedAttendanceRecords, + linkedDocuments, + linkedEmployees, + linkedEvents, + linkedGuestVisits, + linkedGuests, + linkedMessages, + linkedPosts, + linkedProcurementRequests, + linkedRequests, + ]); const linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount; const commentsBadgeCount = viewTask?.comments_count ?? taskComments.length; const attachmentsBadgeCount = viewTask?.attachments_count ?? taskAttachments.length; @@ -4068,6 +4281,8 @@ function TasksPageContent() { setTaskChecklistDraft(""); setCommentDraft(""); setCommentFiles([]); + setCoverPickerOpen(false); + setCoverPickerLoading(false); setLinkedObjectsOpen(false); setCommentsOpen(false); setAttachmentsOpen(false); @@ -4244,6 +4459,8 @@ function TasksPageContent() { setTaskDetailsOpen(false); setCommentDraft(""); setCommentFiles([]); + setCoverPickerOpen(false); + setCoverPickerLoading(false); setCardMenuTaskId(null); void loadTaskAutomationButtons(task.id); }, [loadTaskAutomationButtons]); @@ -4290,6 +4507,8 @@ function TasksPageContent() { setActivityOpen(false); setCommentDraft(""); setCommentFiles([]); + setCoverPickerOpen(false); + setCoverPickerLoading(false); setTaskChecklistDraft(""); setParticipantModalOpen(false); setParticipantModalSearch(""); @@ -5029,13 +5248,19 @@ function TasksPageContent() { setTaskFormAttachments((current) => [...current, ...uploaded]); } - if (selectedCoverAttachmentId) { - await apiClient.setTaskCover(taskId, selectedCoverAttachmentId); - } else { - await apiClient.clearTaskCover(taskId); + if (taskFormCoverChanged) { + if (selectedCoverAttachmentId) { + await apiClient.setTaskCover(taskId, { + kind: "attachment", + source_id: selectedCoverAttachmentId, + }); + } else { + await apiClient.clearTaskCover(taskId); + } } setTaskFormCoverAttachmentId(selectedCoverAttachmentId); setTaskFormCoverPendingFile(null); + setTaskFormCoverChanged(false); }, [ taskFormChecklistItems, taskFormDirtyChecklistItemIds, @@ -5045,6 +5270,7 @@ function TasksPageContent() { taskFormPendingFiles, taskFormPendingChecklistItems, taskFormCoverAttachmentId, + taskFormCoverChanged, taskFormCoverPendingFile, taskFormRemovedAttachmentIds, taskFormRemovedChecklistItemIds, @@ -5330,14 +5556,18 @@ function TasksPageContent() { setTaskAttachmentsUploading(true); setError(null); try { - const updatedTask = attachment.is_cover - ? await apiClient.clearTaskCover(viewTask.id) - : await apiClient.setTaskCover(viewTask.id, attachment.id); - const coverId = updatedTask.cover_attachment?.id ?? null; - setTaskAttachments((current) => current.map((item) => ({ - ...item, - is_cover: item.id === coverId, - }))); + const isCurrentCover = ( + viewTask.cover?.kind === "attachment" + && viewTask.cover.attachment?.id === attachment.id + ); + if (isCurrentCover) { + await apiClient.clearTaskCover(viewTask.id); + } else { + await apiClient.setTaskCover(viewTask.id, { + kind: "attachment", + source_id: attachment.id, + }); + } await loadBoard(board.id); if (activityOpen) await loadTaskActivity(viewTask.id); } catch (attachmentError) { @@ -5347,6 +5577,89 @@ function TasksPageContent() { } }, [activityOpen, board, loadBoard, loadTaskActivity, taskAttachmentsUploading, viewTask]); + const openTaskCoverPicker = useCallback(async () => { + if (!viewTask || coverPickerLoading) return; + setTaskAddMenuOpen(false); + setCoverPickerOpen(true); + setCoverPickerLoading(true); + setError(null); + try { + await Promise.all([ + loadTaskAttachments(viewTask.id), + loadTaskChecklist(viewTask.id), + loadTaskComments(viewTask.id), + loadTaskLinkedObjects(viewTask.id), + ]); + } finally { + setCoverPickerLoading(false); + } + }, [ + coverPickerLoading, + loadTaskAttachments, + loadTaskChecklist, + loadTaskComments, + loadTaskLinkedObjects, + viewTask, + ]); + + const selectViewedTaskCover = useCallback(async ( + kind: TaskCoverType, + sourceId?: number, + ) => { + if (!board || !viewTask || saving) return; + setSaving(true); + setError(null); + try { + await apiClient.setTaskCover(viewTask.id, { + kind, + ...(sourceId ? { source_id: sourceId } : {}), + }); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + setCoverPickerOpen(false); + } catch (coverError) { + setError(getTaskError(coverError, "Не удалось установить обложку задачи")); + } finally { + setSaving(false); + } + }, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]); + + const clearViewedTaskCover = useCallback(async () => { + if (!board || !viewTask || saving) return; + setSaving(true); + setError(null); + try { + await apiClient.clearTaskCover(viewTask.id); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + setCoverPickerOpen(false); + } catch (coverError) { + setError(getTaskError(coverError, "Не удалось убрать обложку задачи")); + } finally { + setSaving(false); + } + }, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]); + + const toggleViewedTaskResourceCover = useCallback(async ( + kind: TaskCoverType, + sourceId?: number, + ) => { + if (!viewTask || saving) return; + const cover = viewTask.cover; + const isCurrent = cover?.kind === kind && ( + kind === "checklist" + || (kind === "comment" && cover.comment?.id === sourceId) + || (kind === "external_link" && cover.external_link?.id === sourceId) + || (kind === "linked_object" && cover.linked_object?.id === sourceId) + || (kind === "attachment" && cover.attachment?.id === sourceId) + ); + if (isCurrent) { + await clearViewedTaskCover(); + return; + } + await selectViewedTaskCover(kind, sourceId); + }, [clearViewedTaskCover, saving, selectViewedTaskCover, viewTask]); + const addViewedTaskChecklistItem = useCallback(async () => { const title = taskChecklistDraft.trim(); if (!board || !viewTask || !title || taskChecklistSaving) return; @@ -5495,23 +5808,31 @@ function TasksPageContent() { setTaskFormCoverAttachmentId((current) => ( current === attachment.id ? null : current )); - }, []); + if (taskFormCoverAttachmentId === attachment.id) { + setTaskFormCoverChanged(true); + } + }, [taskFormCoverAttachmentId]); const removePendingTaskFormFile = useCallback((file: File) => { setTaskFormPendingFiles((current) => current.filter((item) => item !== file)); setTaskFormCoverPendingFile((current) => (current === file ? null : current)); - }, []); + if (taskFormCoverPendingFile === file) { + setTaskFormCoverChanged(true); + } + }, [taskFormCoverPendingFile]); const toggleExistingTaskFormCover = useCallback((attachment: TaskAttachment) => { setTaskFormCoverAttachmentId((current) => ( current === attachment.id ? null : attachment.id )); setTaskFormCoverPendingFile(null); + setTaskFormCoverChanged(true); }, []); const togglePendingTaskFormCover = useCallback((file: File) => { setTaskFormCoverPendingFile((current) => (current === file ? null : file)); setTaskFormCoverAttachmentId(null); + setTaskFormCoverChanged(true); }, []); const removeExistingTaskFormDocument = useCallback((link: TaskLinkedDocument) => { @@ -7213,6 +7534,14 @@ function TasksPageContent() { Метки + + {checklistBadgeTotal > 0 ? ( + void toggleViewedTaskResourceCover("checklist")} + /> + ) : null} - {isImageAttachment(attachment) ? ( + {isMediaAttachment(attachment) ? ( @@ -7932,8 +8268,16 @@ function TasksPageContent() { {comment.is_edited ? " · изменён" : ""}

- {editingCommentId !== comment.id && (canEditComment || canDeleteComment) ? ( + {editingCommentId !== comment.id ? (
+ void toggleViewedTaskResourceCover("comment", comment.id)} + /> {canEditComment ? ( + +
+ )} + > + {coverPickerLoading ? ( +
+ +
+ ) : viewTask ? ( +
+ {taskAttachments.some(isMediaAttachment) ? ( +
+

Фото и видео

+
+ {taskAttachments.filter(isMediaAttachment).map((attachment) => ( + void selectViewedTaskCover("attachment", attachment.id)} + /> + ))} +
+
+ ) : null} + + {taskChecklist.length > 0 ? ( +
+

Чек-лист

+ item.is_completed).length}/${taskChecklist.length} выполнено`} + selected={viewTask.cover?.kind === "checklist"} + disabled={saving} + onSelect={() => void selectViewedTaskCover("checklist")} + /> +
+ ) : null} + + {taskComments.length > 0 ? ( +
+

Комментарии

+
+ {taskComments.map((comment) => ( + void selectViewedTaskCover("comment", comment.id)} + /> + ))} +
+
+ ) : null} + + {taskExternalLinks.length > 0 ? ( +
+

Ссылки

+
+ {taskExternalLinks.map((link) => ( + void selectViewedTaskCover("external_link", link.id)} + /> + ))} +
+
+ ) : null} + + {taskCoverLinkedOptions.length > 0 ? ( +
+

Связанные объекты

+
+ {taskCoverLinkedOptions.map((option) => ( + void selectViewedTaskCover("linked_object", option.id)} + /> + ))} +
+
+ ) : null} + + {!taskAttachments.some(isMediaAttachment) + && taskChecklist.length === 0 + && taskComments.length === 0 + && taskExternalLinks.length === 0 + && taskCoverLinkedOptions.length === 0 ? ( +
+ +

+ Добавьте фото, видео, чек-лист, комментарий, ссылку или связанный объект. +

+
+ ) : null} +
+ ) : null} + + toggleExistingTaskFormCover(attachment)} onRemove={() => removeExistingTaskFormAttachment(attachment)} @@ -9108,7 +9705,7 @@ function TasksPageContent() { title={file.name} meta={formatFileSize(file.size)} pending - canUseAsCover={isImageFile(file)} + canUseAsCover={isMediaFile(file)} isCover={taskFormCoverPendingFile === file} onToggleCover={() => togglePendingTaskFormCover(file)} onRemove={() => removePendingTaskFormFile(file)} diff --git a/frontend/src/components/tasks/TaskCoverImage.tsx b/frontend/src/components/tasks/TaskCoverImage.tsx deleted file mode 100644 index aa381573..00000000 --- a/frontend/src/components/tasks/TaskCoverImage.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"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/components/tasks/TaskCoverPreview.tsx b/frontend/src/components/tasks/TaskCoverPreview.tsx new file mode 100644 index 00000000..e2330d31 --- /dev/null +++ b/frontend/src/components/tasks/TaskCoverPreview.tsx @@ -0,0 +1,229 @@ +"use client"; + +import Image from "next/image"; +import { + Check, + ExternalLink, + FileText, + ImagePlus, + Link2, + Loader2, + MessageSquare, + Play, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { apiClient } from "@/lib/api"; +import { displayUserName } from "@/lib/shared"; +import type { TaskAttachment, TaskCover } from "@/types/api"; + +function isImage(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 isVideo(attachment: TaskAttachment): boolean { + if (attachment.mime_type?.toLowerCase().startsWith("video/")) return true; + return /\.(m4v|mkv|mov|mp4|mpeg|mpg|ogv|webm)$/i.test(attachment.file_name); +} + +function AuthenticatedCoverMedia({ + taskId, + attachment, +}: { + taskId: number; + attachment: TaskAttachment; +}) { + const [source, setSource] = useState(null); + const [failed, setFailed] = useState(false); + const video = isVideo(attachment); + 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); + setSource(objectUrl); + }) + .catch(() => { + if (active) setFailed(true); + }); + + return () => { + active = false; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [attachmentId, downloadUrl, fileName, taskId]); + + return ( +
+ {source && video ? ( + <> +
+ ); +} + +export default function TaskCoverPreview({ + taskId, + cover, + className = "", +}: { + taskId: number; + cover: TaskCover; + className?: string; +}) { + const commentMedia = useMemo( + () => cover.comment?.attachments.find((attachment) => ( + isImage(attachment) || isVideo(attachment) + )), + [cover.comment?.attachments], + ); + const media = cover.kind === "attachment" ? cover.attachment : commentMedia; + + if (media) { + return ( +
+ +
+ ); + } + + if (cover.kind === "checklist" && cover.checklist) { + const { completed, items, total } = cover.checklist; + const progress = total > 0 ? Math.round((completed / total) * 100) : 0; + return ( +
+
+
+
+
+ + {completed}/{total} + +
+
+ {items.map((item) => ( +
+ + {item.is_completed ? : null} + + + {item.title} + +
+ ))} +
+
+ ); + } + + if (cover.kind === "comment" && cover.comment) { + return ( +
+
+ + {displayUserName(cover.comment.author)} +
+

+ {cover.comment.text || "Комментарий с вложением"} +

+
+ ); + } + + if (cover.kind === "external_link" && cover.external_link) { + let hostname = cover.external_link.url; + try { + hostname = new URL(cover.external_link.url).hostname; + } catch { + // Keep the original URL when it cannot be parsed by the browser. + } + return ( +
+
+ + {cover.external_link.title || hostname} +
+

{hostname}

+

+ {cover.external_link.url} +

+
+ ); + } + + if (cover.kind === "linked_object" && cover.linked_object) { + return ( +
+
+ + {cover.linked_object.kind_display} +
+

+ {cover.linked_object.title} +

+ {cover.linked_object.description ? ( +

+ {cover.linked_object.description} +

+ ) : null} +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts index 986a5ded..0ae8aed6 100644 --- a/frontend/src/lib/api/tasks.ts +++ b/frontend/src/lib/api/tasks.ts @@ -13,6 +13,7 @@ import type { TaskAutomationRun, TaskChecklistItem, TaskComment, + TaskCoverType, TaskLabel, TaskLinkedAttendanceRecord, TaskLinkedCalendarEvent, @@ -263,10 +264,13 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) { request(`/api/v1/tasks/${taskId}/attachments/${attachmentId}/`, { method: "DELETE", }), - setTaskCover: (taskId: number, attachmentId: number): Promise => + setTaskCover: ( + taskId: number, + data: { kind: TaskCoverType; source_id?: number }, + ): Promise => request(`/api/v1/tasks/${taskId}/cover/`, { method: "POST", - body: JSON.stringify({ attachment_id: attachmentId }), + body: JSON.stringify(data), }), clearTaskCover: (taskId: number): Promise => request(`/api/v1/tasks/${taskId}/cover/`, { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 78596112..ce96a7fe 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -334,6 +334,7 @@ export interface TaskCard { linked_objects_count?: number; comments_count?: number; attachments_count?: number; + cover?: TaskCover | null; cover_attachment?: TaskAttachment | null; checklist_total?: number; checklist_completed?: number; @@ -636,7 +637,6 @@ export interface TaskAttachment { file_name: string; file_size: number; mime_type?: string; - is_cover: boolean; uploaded_by?: User; created_at: string; download_url: string; @@ -666,6 +666,57 @@ export interface TaskComment { attachments?: TaskAttachment[]; } +export type TaskCoverType = + | "attachment" + | "checklist" + | "comment" + | "external_link" + | "linked_object"; + +export interface TaskCover { + id: number; + kind: TaskCoverType; + kind_display: string; + attachment?: TaskAttachment | null; + checklist?: { + total: number; + completed: number; + items: Array<{ + id: number; + title: string; + is_completed: boolean; + }>; + } | null; + comment?: { + id: number; + author: User; + text: string; + created_at: string; + attachments: TaskAttachment[]; + } | null; + external_link?: TaskExternalLink | null; + linked_object?: { + id: number; + kind: + | "post" + | "message" + | "calendar_event" + | "document" + | "request" + | "procurement_request" + | "employee" + | "guest" + | "guest_visit" + | "attendance_record"; + kind_display: string; + object_id: number; + title: string; + description: string; + image_url?: string | null; + } | null; + updated_at: string; +} + export interface MessageLinkedTask { link_id: number; id: number;