From f4a6b2de9bd69f8c4cad96abebb865d1d65fd0f1 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 5 Aug 2026 18:33:40 +0300 Subject: [PATCH] feat(tasks): improve board navigation and collaboration --- backend/api/v1/tasks/serializers.py | 33 + backend/api/v1/tasks/views.py | 103 ++- backend/tasks/admin.py | 22 +- .../tasks/migrations/0021_taskboard_avatar.py | 18 + ...2_taskboardplacement_is_pinned_and_more.py | 44 ++ .../0023_taskusersettings_default_group.py | 26 + backend/tasks/models.py | 25 +- backend/tasks/notifications/handlers.py | 41 +- backend/tasks/signals.py | 66 +- .../api/v1/tasks/test_task_notifications.py | 40 +- backend/tests/api/v1/tasks/test_tasks_api.py | 206 +++++- frontend/src/app/globals.css | 1 + frontend/src/app/tasks/page.tsx | 366 +++++++-- .../messages/MessagesChatListItem.tsx | 2 +- .../src/components/tasks/TaskBoardAvatar.tsx | 54 ++ .../components/tasks/TaskBoardNavigator.tsx | 692 +++++++++++++----- frontend/src/lib/api/tasks.ts | 23 + frontend/src/types/api.ts | 7 + 18 files changed, 1524 insertions(+), 245 deletions(-) create mode 100644 backend/tasks/migrations/0021_taskboard_avatar.py create mode 100644 backend/tasks/migrations/0022_taskboardplacement_is_pinned_and_more.py create mode 100644 backend/tasks/migrations/0023_taskusersettings_default_group.py create mode 100644 frontend/src/components/tasks/TaskBoardAvatar.tsx diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 66a976f7..c9141fcf 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -50,6 +50,7 @@ from tasks.models import ( TaskLabel, TaskLinkedObject, TaskLinkedObjectKind, + TaskUserSettings, ) User = get_user_model() @@ -493,6 +494,16 @@ class TaskBoardSerializer(serializers.ModelSerializer): allow_null=True, default=None, ) + is_pinned = serializers.BooleanField( + source="current_user_is_pinned", + read_only=True, + default=False, + ) + notifications_enabled = serializers.BooleanField( + source="current_user_notifications_enabled", + read_only=True, + default=True, + ) class Meta: model = TaskBoard @@ -500,6 +511,7 @@ class TaskBoardSerializer(serializers.ModelSerializer): "id", "name", "description", + "avatar", "access_scope", "created_by", "members", @@ -513,6 +525,8 @@ class TaskBoardSerializer(serializers.ModelSerializer): "tasks_count", "is_default_for_current_user", "group_id", + "is_pinned", + "notifications_enabled", "created_at", "updated_at", ] @@ -558,6 +572,7 @@ class TaskBoardSummarySerializer(TaskBoardSerializer): "id", "name", "description", + "avatar", "access_scope", "created_by", "member_details", @@ -567,6 +582,8 @@ class TaskBoardSummarySerializer(TaskBoardSerializer): "completed_tasks_count", "is_default_for_current_user", "group_id", + "is_pinned", + "notifications_enabled", "created_at", "updated_at", ] @@ -574,6 +591,7 @@ class TaskBoardSummarySerializer(TaskBoardSerializer): class TaskBoardGroupSerializer(serializers.ModelSerializer): boards_count = serializers.IntegerField(read_only=True, default=0) + is_default_for_current_user = serializers.SerializerMethodField() class Meta: model = TaskBoardGroup @@ -582,6 +600,7 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer): "name", "position", "boards_count", + "is_default_for_current_user", "created_at", "updated_at", ] @@ -602,6 +621,20 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer): raise serializers.ValidationError("Группа с таким названием уже существует.") return name + @extend_schema_field(serializers.BooleanField()) + def get_is_default_for_current_user(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + if not user or not getattr(user, "is_authenticated", False): + return False + if not hasattr(self, "_default_group_id"): + self._default_group_id = ( + TaskUserSettings.objects.filter(user=user) + .values_list("default_group_id", flat=True) + .first() + ) + return self._default_group_id == obj.id + class TaskLinkedPostSerializer(serializers.ModelSerializer): created_by = EmployeeBriefSerializer(read_only=True) diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 4c9568fb..cae3f1b5 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -6,7 +6,9 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.auth import get_user_model from django.db import transaction from django.db.models import ( + BooleanField, Count, + DateTimeField, IntegerField, Max, OuterRef, @@ -182,10 +184,10 @@ class TaskBoardViewSet(viewsets.ModelViewSet): return super().get_serializer_class() def get_queryset(self): - current_group_id = TaskBoardPlacement.objects.filter( + current_placement = TaskBoardPlacement.objects.filter( user=self.request.user, board_id=OuterRef("pk"), - ).values("group_id")[:1] + ) queryset = ( TaskBoard.objects.filter(is_archived=False) .filter(task_board_access_q(self.request.user)) @@ -193,9 +195,27 @@ class TaskBoardViewSet(viewsets.ModelViewSet): .prefetch_related("members", "departments") .annotate( current_user_group_id=Subquery( - current_group_id, + current_placement.values("group_id")[:1], output_field=IntegerField(), ), + current_user_is_pinned=Coalesce( + Subquery( + current_placement.values("is_pinned")[:1], + output_field=BooleanField(), + ), + Value(False), + ), + current_user_pinned_at=Subquery( + current_placement.values("pinned_at")[:1], + output_field=DateTimeField(), + ), + current_user_notifications_enabled=Coalesce( + Subquery( + current_placement.values("notifications_enabled")[:1], + output_field=BooleanField(), + ), + Value(True), + ), tasks_count=Count("tasks", distinct=True), open_tasks_count=Count( "tasks", @@ -209,7 +229,12 @@ class TaskBoardViewSet(viewsets.ModelViewSet): ), ) .distinct() - .order_by("name", "id") + .order_by( + "-current_user_is_pinned", + "-current_user_pinned_at", + "name", + "id", + ) ) if not ( self.action == "list" @@ -333,10 +358,16 @@ class TaskBoardViewSet(viewsets.ModelViewSet): board = self.get_object() group_id = request.data.get("group") if group_id in (None, ""): - TaskBoardPlacement.objects.filter( + placement = TaskBoardPlacement.objects.filter( user=request.user, board=board, - ).delete() + ).first() + if placement: + placement.group = None + if not placement.is_pinned and placement.notifications_enabled: + placement.delete() + else: + placement.save(update_fields=["group", "updated_at"]) return Response({"board": board.id, "group_id": None}) try: @@ -358,6 +389,41 @@ class TaskBoardViewSet(viewsets.ModelViewSet): ) return Response({"board": board.id, "group_id": group.id}) + @action(detail=True, methods=["post"]) + def pin(self, request, pk=None): + board = self.get_object() + placement, _ = TaskBoardPlacement.objects.get_or_create( + user=request.user, + board=board, + defaults={"group": None}, + ) + placement.is_pinned = not placement.is_pinned + placement.pinned_at = timezone.now() if placement.is_pinned else None + is_pinned = placement.is_pinned + if not placement.is_pinned and placement.group_id is None and placement.notifications_enabled: + placement.delete() + else: + placement.save(update_fields=["is_pinned", "pinned_at", "updated_at"]) + return Response({"ok": True, "is_pinned": is_pinned}) + + @action(detail=True, methods=["post"]) + def notifications(self, request, pk=None): + board = self.get_object() + placement, _ = TaskBoardPlacement.objects.get_or_create( + user=request.user, + board=board, + defaults={"group": None}, + ) + placement.notifications_enabled = not placement.notifications_enabled + notifications_enabled = placement.notifications_enabled + if placement.notifications_enabled and placement.group_id is None and not placement.is_pinned: + placement.delete() + else: + placement.save(update_fields=["notifications_enabled", "updated_at"]) + return Response( + {"ok": True, "notifications_enabled": notifications_enabled} + ) + @action(detail=True, methods=["post"], url_path="notifications/read") def mark_notifications_read(self, request, pk=None): board = self.get_object() @@ -389,6 +455,31 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet): 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") + def set_default(self, request, pk=None): + group = self.get_object() + settings_obj, _ = TaskUserSettings.objects.get_or_create(user=request.user) + is_default = settings_obj.default_group_id != group.id + settings_obj.default_group = group if is_default else None + settings_obj.save(update_fields=["default_group", "updated_at"]) + serializer = self.get_serializer(group) + return Response( + { + "group": serializer.data, + "is_default_for_current_user": is_default, + } + ) + + def perform_destroy(self, instance): + user = self.request.user + instance.delete() + TaskBoardPlacement.objects.filter( + user=user, + group__isnull=True, + is_pinned=False, + notifications_enabled=True, + ).delete() + class TaskColumnViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index 57f28b80..2a17be35 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -52,7 +52,15 @@ class TaskBoardGroupAdmin(admin.ModelAdmin): @admin.register(TaskBoardPlacement) class TaskBoardPlacementAdmin(admin.ModelAdmin): - list_display = ["board", "group", "user", "updated_at"] + list_display = [ + "board", + "group", + "user", + "is_pinned", + "notifications_enabled", + "updated_at", + ] + list_filter = ["is_pinned", "notifications_enabled", "group"] search_fields = ["board__name", "group__name", "user__email"] autocomplete_fields = ["board", "group", "user"] @@ -134,6 +142,12 @@ class TaskActivityAdmin(admin.ModelAdmin): @admin.register(TaskUserSettings) class TaskUserSettingsAdmin(admin.ModelAdmin): - list_display = ["user", "default_board", "updated_at"] - search_fields = ["user__email", "user__first_name", "user__last_name", "default_board__name"] - autocomplete_fields = ["user", "default_board"] + list_display = ["user", "default_board", "default_group", "updated_at"] + search_fields = [ + "user__email", + "user__first_name", + "user__last_name", + "default_board__name", + "default_group__name", + ] + autocomplete_fields = ["user", "default_board", "default_group"] diff --git a/backend/tasks/migrations/0021_taskboard_avatar.py b/backend/tasks/migrations/0021_taskboard_avatar.py new file mode 100644 index 00000000..25b0ca49 --- /dev/null +++ b/backend/tasks/migrations/0021_taskboard_avatar.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2026-08-05 14:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0020_taskboardgroup_taskboardplacement_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='taskboard', + name='avatar', + field=models.ImageField(blank=True, null=True, upload_to='task_board_avatars/%Y/%m/', verbose_name='Аватар доски'), + ), + ] diff --git a/backend/tasks/migrations/0022_taskboardplacement_is_pinned_and_more.py b/backend/tasks/migrations/0022_taskboardplacement_is_pinned_and_more.py new file mode 100644 index 00000000..7f6e5891 --- /dev/null +++ b/backend/tasks/migrations/0022_taskboardplacement_is_pinned_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.4 on 2026-08-05 14:24 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0021_taskboard_avatar'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='taskboardplacement', + name='is_pinned', + field=models.BooleanField(default=False, verbose_name='Закреплена'), + ), + migrations.AddField( + model_name='taskboardplacement', + name='notifications_enabled', + field=models.BooleanField(default=True, verbose_name='Уведомления'), + ), + migrations.AddField( + model_name='taskboardplacement', + name='pinned_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Закреплена в'), + ), + migrations.AlterField( + model_name='taskboardplacement', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='board_placements', to='tasks.taskboardgroup', verbose_name='Группа'), + ), + migrations.AddIndex( + model_name='taskboardplacement', + index=models.Index(fields=['user', 'is_pinned'], name='tasks_taskb_user_id_b1c3b1_idx'), + ), + migrations.AddIndex( + model_name='taskboardplacement', + index=models.Index(fields=['board', 'user'], name='tasks_taskb_board_i_8de391_idx'), + ), + ] diff --git a/backend/tasks/migrations/0023_taskusersettings_default_group.py b/backend/tasks/migrations/0023_taskusersettings_default_group.py new file mode 100644 index 00000000..968ca922 --- /dev/null +++ b/backend/tasks/migrations/0023_taskusersettings_default_group.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.4 on 2026-08-05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tasks", "0022_taskboardplacement_is_pinned_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="taskusersettings", + name="default_group", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="default_for_user_settings", + to="tasks.taskboardgroup", + verbose_name="Группа досок по умолчанию", + ), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index dcbe861f..5c42f7da 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -22,6 +22,12 @@ class TaskBoardAccessScope(models.TextChoices): class TaskBoard(models.Model): name = models.CharField("Название", max_length=255) description = models.TextField("Описание", blank=True) + avatar = models.ImageField( + "Аватар доски", + upload_to="task_board_avatars/%Y/%m/", + null=True, + blank=True, + ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, @@ -101,10 +107,15 @@ class TaskBoardPlacement(models.Model): ) group = models.ForeignKey( TaskBoardGroup, - on_delete=models.CASCADE, + on_delete=models.SET_NULL, + null=True, + blank=True, related_name="board_placements", verbose_name="Группа", ) + is_pinned = models.BooleanField("Закреплена", default=False) + pinned_at = models.DateTimeField("Закреплена в", null=True, blank=True) + notifications_enabled = models.BooleanField("Уведомления", default=True) created_at = models.DateTimeField("Создано", auto_now_add=True) updated_at = models.DateTimeField("Обновлено", auto_now=True) @@ -117,6 +128,10 @@ class TaskBoardPlacement(models.Model): name="uniq_task_board_placement_user_board", ) ] + indexes = [ + models.Index(fields=["user", "is_pinned"]), + models.Index(fields=["board", "user"]), + ] def __str__(self): return f"{self.user}: {self.board} -> {self.group}" @@ -137,6 +152,14 @@ class TaskUserSettings(models.Model): related_name="default_for_user_settings", verbose_name="Доска по умолчанию", ) + default_group = models.ForeignKey( + TaskBoardGroup, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="default_for_user_settings", + verbose_name="Группа досок по умолчанию", + ) created_at = models.DateTimeField("Создано", auto_now_add=True) updated_at = models.DateTimeField("Обновлено", auto_now=True) diff --git a/backend/tasks/notifications/handlers.py b/backend/tasks/notifications/handlers.py index ee3d0b5a..1363580b 100644 --- a/backend/tasks/notifications/handlers.py +++ b/backend/tasks/notifications/handlers.py @@ -12,6 +12,7 @@ from tasks.models import ( Task, TaskActivityAction, TaskActivityObjectKind, + TaskBoardPlacement, TaskLinkedObject, TaskLinkedObjectKind, ) @@ -57,11 +58,18 @@ def _send_task_notification( action_object=None, extra_data: dict | None = None, action_url: str | None = None, + preference_checked: bool = False, ): if not recipient or not getattr(recipient, "is_active", False): - return + return False if _actor_id(actor) and recipient.id == _actor_id(actor): - return + return False + if not preference_checked and TaskBoardPlacement.objects.filter( + board_id=task.board_id, + user_id=recipient.id, + notifications_enabled=False, + ).exists(): + return False notify.send( sender=actor, @@ -73,6 +81,7 @@ def _send_task_notification( action_url=action_url or ActionURLs.task_detail(task), data=_task_data(task, title, extra_data), ) + return True def _notify_many( @@ -87,9 +96,21 @@ def _notify_many( extra_data: dict | None = None, action_url: str | None = None, ): + recipient_list = [ + recipient + for recipient in recipients + if recipient and getattr(recipient, "is_active", False) + ] + muted_ids = set( + TaskBoardPlacement.objects.filter( + board_id=task.board_id, + user_id__in=[recipient.id for recipient in recipient_list], + notifications_enabled=False, + ).values_list("user_id", flat=True) + ) seen = set() - for recipient in recipients: - if not recipient or recipient.id in seen: + for recipient in recipient_list: + if recipient.id in seen or recipient.id in muted_ids: continue seen.add(recipient.id) _send_task_notification( @@ -102,6 +123,7 @@ def _notify_many( action_object=action_object, extra_data=extra_data, action_url=action_url, + preference_checked=True, ) @@ -444,6 +466,12 @@ def notify_board_members_added(board, actor, members): continue if _actor_id(actor) and member.id == _actor_id(actor): continue + if TaskBoardPlacement.objects.filter( + board=board, + user=member, + notifications_enabled=False, + ).exists(): + continue notify.send( sender=actor, recipient=member, @@ -508,7 +536,7 @@ def dispatch_task_due_notifications() -> dict[str, int]: task=task, ): continue - _send_task_notification( + sent = _send_task_notification( recipient=recipient, verb=verb, task=task, @@ -516,8 +544,7 @@ def dispatch_task_due_notifications() -> dict[str, int]: description=description, actor=None, extra_data={"due_date": task.due_date.isoformat()}, - ) - sent = True + ) or sent if not sent: continue if verb == NotificationVerbs.TASK_OVERDUE: diff --git a/backend/tasks/signals.py b/backend/tasks/signals.py index 10ee43c0..464c0868 100644 --- a/backend/tasks/signals.py +++ b/backend/tasks/signals.py @@ -1,8 +1,63 @@ +import logging + +from django.core.files.base import ContentFile from django.db import transaction -from django.db.models.signals import post_delete, post_save +from django.db.models.signals import post_delete, post_save, pre_save from django.dispatch import receiver -from .models import Task, TaskAttachment +from common.image_utils import compress_avatar + +from .models import Task, TaskAttachment, TaskBoard + +logger = logging.getLogger(__name__) + + +def _delete_file_after_commit(field_file): + if not field_file or not field_file.name: + return + storage = field_file.storage + name = field_file.name + transaction.on_commit( + lambda: storage.delete(name) if storage.exists(name) else None + ) + + +@receiver(pre_save, sender=TaskBoard) +def compress_and_cleanup_task_board_avatar(sender, instance, **kwargs): + old_avatar = None + if instance.pk: + old_avatar = ( + sender.objects.filter(pk=instance.pk) + .only("avatar") + .values_list("avatar", flat=True) + .first() + ) + + new_avatar = instance.avatar + if new_avatar and not getattr(new_avatar, "_committed", True): + try: + compressed = compress_avatar(new_avatar.read()) + if compressed: + new_avatar.save( + new_avatar.name, + ContentFile(compressed), + save=False, + ) + except Exception: + new_avatar.seek(0) + logger.exception("Не удалось сжать аватар доски %s", instance.pk) + + new_avatar_name = new_avatar.name if new_avatar else None + if old_avatar and old_avatar != new_avatar_name: + storage = instance._meta.get_field("avatar").storage + transaction.on_commit( + lambda: storage.delete(old_avatar) if storage.exists(old_avatar) else None + ) + + +@receiver(post_delete, sender=TaskBoard) +def delete_task_board_avatar(sender, instance, **kwargs): + _delete_file_after_commit(instance.avatar) @receiver(post_save, sender=Task) @@ -15,9 +70,4 @@ def ensure_task_core_participants(sender, instance, **kwargs): @receiver(post_delete, sender=TaskAttachment) def delete_task_attachment_file(sender, instance, **kwargs): - if not instance.file or not instance.file.name: - return - - storage = instance.file.storage - name = instance.file.name - transaction.on_commit(lambda: storage.delete(name) if storage.exists(name) else None) + _delete_file_after_commit(instance.file) diff --git a/backend/tests/api/v1/tasks/test_task_notifications.py b/backend/tests/api/v1/tasks/test_task_notifications.py index e5538f7c..b469bb23 100644 --- a/backend/tests/api/v1/tasks/test_task_notifications.py +++ b/backend/tests/api/v1/tasks/test_task_notifications.py @@ -9,7 +9,7 @@ from rest_framework import status from rest_framework.test import APIClient from notifications.models import Notification -from tasks.models import Task, TaskBoard, TaskColumn +from tasks.models import Task, TaskBoard, TaskBoardPlacement, TaskColumn from tasks.notifications.config import NotificationVerbs from tasks.notifications.handlers import dispatch_task_due_notifications @@ -97,6 +97,44 @@ def test_create_task_notifies_assignee(api_client): ).exists() +def test_muted_board_suppresses_task_and_due_notifications(api_client): + owner = make_user("task-muted-owner@example.com", "+79995550024") + assignee = make_user("task-muted-assignee@example.com", "+79995550025") + board, column, _done = make_board(owner) + TaskBoardPlacement.objects.create( + board=board, + user=assignee, + notifications_enabled=False, + ) + api_client.force_authenticate(user=owner) + + response = api_client.post( + reverse("api:v1:tasks:task-list"), + { + "board": board.id, + "column": column.id, + "title": "Задача без уведомлений", + "assignee_id": assignee.id, + "due_date": timezone.localdate().isoformat(), + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert not Notification.objects.filter( + recipient=assignee, + data__board_id=board.id, + ).exists() + + dispatch_task_due_notifications() + + assert not Notification.objects.filter( + recipient=assignee, + verb=NotificationVerbs.TASK_DUE_SOON, + data__board_id=board.id, + ).exists() + + def test_reassign_task_notifies_old_and_new_assignees(api_client): owner = make_user("task-reassign-owner@example.com", "+79995550003") old_assignee = make_user("task-reassign-old@example.com", "+79995550004") diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 851129d3..c3ce0809 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -1,5 +1,7 @@ -from unittest.mock import patch +import base64 from datetime import date, timedelta +from pathlib import Path +from unittest.mock import patch import pytest from django.contrib.auth import get_user_model @@ -167,6 +169,54 @@ def test_board_summary_list_returns_navigation_counts_without_nested_tasks( assert "columns" not in result +def test_board_avatar_can_be_uploaded_listed_and_removed( + api_client, + user, + settings, + tmp_path, + django_capture_on_commit_callbacks, +): + settings.MEDIA_ROOT = tmp_path + board = TaskBoard.objects.create(name="Доска с аватаром", created_by=user) + api_client.force_authenticate(user=user) + image_bytes = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + ) + + with patch("tasks.signals.compress_avatar", return_value=image_bytes): + response = api_client.patch( + reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}), + {"avatar": SimpleUploadedFile("board.png", image_bytes, "image/png")}, + format="multipart", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["avatar"].endswith(".png") + board.refresh_from_db() + avatar_path = board.avatar.path + assert board.avatar.name.startswith("task_board_avatars/") + + summary_response = api_client.get( + reverse("api:v1:tasks:task-board-list"), + {"view": "summary"}, + ) + summary = next( + item for item in summary_response.data["results"] if item["id"] == board.id + ) + assert summary["avatar"].endswith(".png") + + with django_capture_on_commit_callbacks(execute=True): + remove_response = api_client.patch( + reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}), + {"avatar": None}, + format="json", + ) + + assert remove_response.status_code == status.HTTP_200_OK + assert remove_response.data["avatar"] is None + assert not Path(avatar_path).exists() + + def test_board_groups_are_personal_and_board_placement_is_independent( api_client, user, @@ -238,6 +288,160 @@ def test_board_groups_are_personal_and_board_placement_is_independent( assert ungrouped_summary_response.data["results"][0]["group_id"] is None +def test_user_can_toggle_personal_default_board_group(api_client, user): + other_user = make_user("task-default-group-other@example.com", "+79994440095") + api_client.force_authenticate(user=user) + first_group = TaskBoardGroup.objects.create(name="Производство", user=user) + second_group = TaskBoardGroup.objects.create(name="Личные проекты", user=user) + + set_response = api_client.post( + reverse( + "api:v1:tasks:task-board-group-set-default", + kwargs={"pk": first_group.id}, + ) + ) + + assert set_response.status_code == status.HTTP_200_OK + assert set_response.data["is_default_for_current_user"] is True + 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") + ) + 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 + + api_client.force_authenticate(user=other_user) + foreign_response = api_client.post( + reverse( + "api:v1:tasks:task-board-group-set-default", + kwargs={"pk": first_group.id}, + ) + ) + assert foreign_response.status_code == status.HTTP_404_NOT_FOUND + + api_client.force_authenticate(user=user) + unset_response = api_client.post( + reverse( + "api:v1:tasks:task-board-group-set-default", + kwargs={"pk": first_group.id}, + ) + ) + assert unset_response.status_code == status.HTTP_200_OK + assert unset_response.data["is_default_for_current_user"] is False + assert unset_response.data["group"]["is_default_for_current_user"] is False + settings_obj = TaskUserSettings.objects.get(user=user) + assert settings_obj.default_group_id is None + + api_client.post( + reverse( + "api:v1:tasks:task-board-group-set-default", + kwargs={"pk": second_group.id}, + ) + ) + api_client.delete( + reverse( + "api:v1:tasks:task-board-group-detail", + kwargs={"pk": second_group.id}, + ) + ) + settings_obj.refresh_from_db() + assert settings_obj.default_group_id is None + + +def test_board_pin_and_notifications_are_personal_and_keep_ungrouped_settings( + api_client, + user, +): + other_user = make_user("task-settings-other@example.com", "+79994440094") + first_board = TaskBoard.objects.create(name="Альфа", created_by=user) + pinned_board = TaskBoard.objects.create(name="Якорь", created_by=user) + api_client.force_authenticate(user=user) + + pin_response = api_client.post( + reverse("api:v1:tasks:task-board-pin", kwargs={"pk": pinned_board.id}) + ) + notifications_response = api_client.post( + reverse( + "api:v1:tasks:task-board-notifications", + kwargs={"pk": pinned_board.id}, + ) + ) + + assert pin_response.status_code == status.HTTP_200_OK + assert pin_response.data["is_pinned"] is True + assert notifications_response.status_code == status.HTTP_200_OK + assert notifications_response.data["notifications_enabled"] is False + placement = TaskBoardPlacement.objects.get(user=user, board=pinned_board) + assert placement.group_id is None + assert placement.is_pinned is True + assert placement.notifications_enabled is False + + group_response = api_client.post( + reverse("api:v1:tasks:task-board-group-list"), + {"name": "Закреплённые проекты"}, + format="json", + ) + api_client.post( + reverse( + "api:v1:tasks:task-board-move-to-group", + kwargs={"pk": pinned_board.id}, + ), + {"group": group_response.data["id"]}, + format="json", + ) + api_client.post( + reverse( + "api:v1:tasks:task-board-move-to-group", + kwargs={"pk": pinned_board.id}, + ), + {"group": None}, + format="json", + ) + placement.refresh_from_db() + assert placement.group_id is None + assert placement.is_pinned is True + assert placement.notifications_enabled is False + + summary_response = api_client.get( + reverse("api:v1:tasks:task-board-list"), + {"view": "summary"}, + ) + summaries = summary_response.data["results"] + assert summaries[0]["id"] == pinned_board.id + assert summaries[0]["is_pinned"] is True + assert summaries[0]["notifications_enabled"] is False + + api_client.force_authenticate(user=other_user) + other_summary_response = api_client.get( + reverse("api:v1:tasks:task-board-list"), + {"view": "summary"}, + ) + other_summary = next( + item + for item in other_summary_response.data["results"] + if item["id"] == pinned_board.id + ) + assert other_summary["is_pinned"] is False + assert other_summary["notifications_enabled"] is True + + api_client.force_authenticate(user=user) + api_client.post( + reverse("api:v1:tasks:task-board-pin", kwargs={"pk": pinned_board.id}) + ) + assert TaskBoardPlacement.objects.filter(user=user, board=pinned_board).exists() + api_client.post( + reverse( + "api:v1:tasks:task-board-notifications", + kwargs={"pk": pinned_board.id}, + ) + ) + assert not TaskBoardPlacement.objects.filter(user=user, board=pinned_board).exists() + assert TaskBoard.objects.filter(id=first_board.id).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") diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 66a211f2..bc9954c4 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -972,6 +972,7 @@ html[data-bs-theme="dark"] .shadow-xl { } .tasks-board-scroll::-webkit-scrollbar { + width: 8px; height: 8px; } diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index f7583a1f..84a64c3c 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -31,6 +31,7 @@ import { FileSignature, FileText, History, + ImagePlus, Kanban, Link2, ListChecks, @@ -52,9 +53,19 @@ import { X, type LucideIcon, } from "lucide-react"; -import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, + type RefObject, +} from "react"; import { AppShell } from "@/components/AppShell"; +import TaskBoardAvatar from "@/components/tasks/TaskBoardAvatar"; import TaskBoardNavigator from "@/components/tasks/TaskBoardNavigator"; import { RequestAvatar } from "@/components/requests/RequestAvatar"; import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls"; @@ -110,6 +121,10 @@ type BoardFormState = { id: number | null; name: string; description: string; + avatar: string | null; + avatar_file: File | null; + avatar_preview: string | null; + avatar_removed: boolean; access: "all" | "private" | "restricted"; member_ids: number[]; department_ids: number[]; @@ -172,6 +187,10 @@ const emptyBoardForm: BoardFormState = { id: null, name: "", description: "", + avatar: null, + avatar_file: null, + avatar_preview: null, + avatar_removed: false, access: "all", member_ids: [], department_ids: [], @@ -684,16 +703,23 @@ function BoardColumn({ setNodeRef(node); onColumnMount(column.id, node); }, [column.id, onColumnMount, setNodeRef]); + const columnBorderClass = isOver + ? "border-[var(--accent-primary)]" + : "border-[var(--border-subtle)]"; return (
-
+
-
+
{tasks.length > 0 ? ( tasks.map((task) => ( void; - fillAvailableHeight?: boolean; + viewportHeight: number; }) { + const cardHeight = viewportHeight > 0 ? Math.max(320, viewportHeight - 12) : 448; + return ( + {board ? ( + + ) : null}

{board?.name || "Доска задач"} @@ -4642,7 +4863,7 @@ function TasksPageContent() {
+ ) : null} +
+

+