From 02c72089a0159298f9dc7c5539b8dd7a0f7d7224 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 5 Aug 2026 09:34:15 +0300 Subject: [PATCH] feat(tasks): expand board collaboration and notifications --- backend/api/v1/tasks/serializers.py | 53 ++ backend/api/v1/tasks/views.py | 153 +++- backend/notifications/api/serializers.py | 6 + backend/notifications/cache.py | 71 ++ backend/tasks/admin.py | 2 +- .../migrations/0019_task_participants.py | 84 ++ backend/tasks/models.py | 6 + backend/tasks/notifications/__init__.py | 4 + backend/tasks/notifications/config.py | 109 +++ backend/tasks/notifications/handlers.py | 209 ++++- backend/tasks/notifications/read_service.py | 83 ++ backend/tasks/signals.py | 12 +- .../notifications/test_notifications_api.py | 27 +- .../api/v1/tasks/test_task_notifications.py | 174 +++- backend/tests/api/v1/tasks/test_tasks_api.py | 249 ++++++ frontend/src/app/tasks/page.tsx | 807 ++++++++++++++++-- frontend/src/components/AppShell.tsx | 2 +- .../src/contexts/NotificationsContext.tsx | 52 ++ frontend/src/lib/api/notifications.ts | 4 + frontend/src/lib/api/tasks.ts | 27 + frontend/src/lib/verbTranslations.ts | 18 + frontend/src/types/api.ts | 1 + 22 files changed, 2046 insertions(+), 107 deletions(-) create mode 100644 backend/tasks/migrations/0019_task_participants.py create mode 100644 backend/tasks/notifications/read_service.py diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 39d43f75..50e2e29b 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -135,6 +135,14 @@ class TaskColumnSerializer(serializers.ModelSerializer): class TaskSerializer(serializers.ModelSerializer): created_by = EmployeeBriefSerializer(read_only=True) assignee = EmployeeBriefSerializer(read_only=True) + participants = EmployeeBriefSerializer(many=True, read_only=True) + participant_ids = serializers.PrimaryKeyRelatedField( + source="participants", + queryset=User.objects.filter(is_active=True), + many=True, + required=False, + write_only=True, + ) assignee_id = serializers.PrimaryKeyRelatedField( source="assignee", queryset=User.objects.all(), @@ -187,6 +195,8 @@ class TaskSerializer(serializers.ModelSerializer): "created_by", "assignee", "assignee_id", + "participants", + "participant_ids", "labels", "label_ids", "priority", @@ -215,6 +225,7 @@ class TaskSerializer(serializers.ModelSerializer): read_only_fields = [ "id", "created_by", + "participants", "board_name", "column_name", "column_color", @@ -360,6 +371,7 @@ class TaskSerializer(serializers.ModelSerializer): board = attrs.get("board") or getattr(self.instance, "board", None) column = attrs.get("column") or getattr(self.instance, "column", None) labels = attrs.get("labels") + participants = attrs.get("participants") if board and not user_can_access_task_board(user, board): raise serializers.ValidationError( @@ -380,10 +392,27 @@ class TaskSerializer(serializers.ModelSerializer): {"label_ids": "Метки должны принадлежать выбранной доске."} ) + if participants is not None and board: + inaccessible_participants = [ + participant.id + for participant in participants + if not user_can_access_task_board(participant, board) + ] + if inaccessible_participants: + raise serializers.ValidationError( + { + "participant_ids": ( + "Участниками могут быть только сотрудники с доступом " + "к этой доске." + ) + } + ) + return attrs def create(self, validated_data): labels = validated_data.pop("labels", []) + participants = list(validated_data.pop("participants", [])) if "position" not in validated_data: column = validated_data["column"] max_position = ( @@ -394,13 +423,37 @@ class TaskSerializer(serializers.ModelSerializer): ) validated_data["position"] = max_position + 1000 task = Task.objects.create(**validated_data) + participant_ids = {participant.id for participant in participants} + participant_ids.add(task.created_by_id) + if task.assignee_id: + participant_ids.add(task.assignee_id) + task.participants.set(participant_ids) if labels: task.labels.set(labels) return task def update(self, instance, validated_data): + previous_assignee_id = instance.assignee_id labels = validated_data.pop("labels", None) + participants = validated_data.pop("participants", None) task = super().update(instance, validated_data) + if participants is not None: + participant_ids = {participant.id for participant in participants} + participant_ids.add(task.created_by_id) + if previous_assignee_id: + participant_ids.add(previous_assignee_id) + if task.assignee_id: + participant_ids.add(task.assignee_id) + task.participants.set(participant_ids) + else: + retained_participant_ids = [task.assignee_id, previous_assignee_id] + task.participants.add( + *[ + participant_id + for participant_id in retained_participant_ids + if participant_id + ] + ) if labels is not None: task.labels.set(labels) return task diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 78effd06..7911dcf0 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -61,11 +61,14 @@ from tasks.models import ( ) from tasks.notifications import ( notify_board_members_added, + notify_task_activity_change, notify_task_comment, notify_task_created, + notify_task_deleted, notify_task_moved, notify_task_updated, ) +from tasks.notifications.read_service import TaskBoardNotificationReadService from tasks.realtime import ( get_task_board_recipient_ids, send_task_board_update, @@ -116,6 +119,8 @@ def create_task_activity( object_id=None, metadata=None, ): + if actor and getattr(actor, "is_authenticated", False): + task.participants.add(actor) TaskActivity.objects.create( task=task, actor=actor if getattr(actor, "is_authenticated", False) else None, @@ -124,6 +129,13 @@ def create_task_activity( object_id=object_id, metadata=metadata or {}, ) + notify_task_activity_change( + task, + actor, + action, + object_kind=object_kind or "", + metadata=metadata or {}, + ) def normalize_column_positions(column): @@ -169,6 +181,7 @@ class TaskBoardViewSet(viewsets.ModelViewSet): "labels", "tasks__created_by", "tasks__assignee", + "tasks__participants", "tasks__labels", "tasks__linked_objects", "tasks__external_links", @@ -226,6 +239,22 @@ class TaskBoardViewSet(viewsets.ModelViewSet): board_id=board_id, ) + @action(detail=True, methods=["get"], url_path="participant-candidates") + def participant_candidates(self, request, pk=None): + board = self.get_object() + recipient_ids = get_task_board_recipient_ids(board) + candidates = Employee.objects.filter( + id__in=recipient_ids, + is_active=True, + ).order_by("last_name", "first_name", "id") + return Response( + EmployeeBriefSerializer( + candidates, + many=True, + context=self.get_serializer_context(), + ).data + ) + @action(detail=False, methods=["get"], url_path="default") def default(self, request): queryset = self.get_queryset() @@ -263,6 +292,21 @@ class TaskBoardViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(self.get_queryset().get(id=board.id)) return Response(serializer.data) + @action(detail=True, methods=["post"], url_path="notifications/read") + def mark_notifications_read(self, request, pk=None): + board = self.get_object() + result = TaskBoardNotificationReadService().mark_board_notifications_as_read( + user=request.user, + board=board, + ) + return Response( + { + "status": "success", + "count": result.count, + "notification_ids": result.notification_ids, + } + ) + class TaskColumnViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] @@ -293,12 +337,111 @@ class TaskColumnViewSet(viewsets.ModelViewSet): column = serializer.save() send_task_board_update(column.board, "updated", "column", column.id) + def destroy(self, request, *args, **kwargs): + column = self.get_object() + if column.tasks.exists(): + return Response( + { + "detail": ( + "Переместите задачи в другую колонку перед удалением." + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + if column.board.columns.filter(is_archived=False).count() <= 1: + return Response( + {"detail": "На доске должна остаться хотя бы одна колонка."}, + status=status.HTTP_400_BAD_REQUEST, + ) + return super().destroy(request, *args, **kwargs) + def perform_destroy(self, instance): board = instance.board column_id = instance.id instance.delete() send_task_board_update(board, "deleted", "column", column_id) + @action(detail=False, methods=["post"], url_path="reorder") + def reorder(self, request): + """Сохраняет единый для всех участников порядок колонок доски.""" + raw_board_id = request.data.get("board") + raw_column_ids = request.data.get("column_ids") + if not raw_board_id: + return Response( + {"board": ["Укажите доску."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + board_id = int(raw_board_id) + except (TypeError, ValueError): + return Response( + {"board": ["Идентификатор доски должен быть числом."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not isinstance(raw_column_ids, list) or not raw_column_ids: + return Response( + {"column_ids": ["Передайте полный порядок активных колонок."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + column_ids = [int(column_id) for column_id in raw_column_ids] + except (TypeError, ValueError): + return Response( + {"column_ids": ["Идентификаторы колонок должны быть числами."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + if len(column_ids) != len(set(column_ids)): + return Response( + {"column_ids": ["Колонки в списке не должны повторяться."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + board = get_object_or_404( + TaskBoard.objects.filter( + task_board_access_q(request.user), + is_archived=False, + ), + pk=board_id, + ) + + with transaction.atomic(): + columns = list( + TaskColumn.objects.select_for_update() + .filter(board=board, is_archived=False) + .order_by("position", "id") + ) + columns_by_id = {column.id: column for column in columns} + if set(column_ids) != set(columns_by_id): + return Response( + { + "column_ids": [ + "Список должен содержать все активные колонки доски." + ] + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + updated_at = timezone.now() + ordered_columns = [] + for index, column_id in enumerate(column_ids, start=1): + column = columns_by_id[column_id] + column.position = index * 1000 + column.updated_at = updated_at + ordered_columns.append(column) + TaskColumn.objects.bulk_update( + ordered_columns, + ["position", "updated_at"], + ) + send_task_board_update( + board, + "reordered", + "column", + extra={"column_ids": column_ids}, + ) + + return Response({"board": board.id, "column_ids": column_ids}) + class TaskLabelViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] @@ -363,7 +506,7 @@ class TaskViewSet(viewsets.ModelViewSet): ) ) .select_related("board", "column", "created_by", "assignee") - .prefetch_related("labels", "checklist_items") + .prefetch_related("labels", "participants", "checklist_items") .annotate( linked_posts_count=Count( "linked_objects", @@ -485,6 +628,9 @@ class TaskViewSet(viewsets.ModelViewSet): "priority": instance.priority, "due_date": instance.due_date.isoformat() if instance.due_date else None, "label_ids": sorted(instance.labels.values_list("id", flat=True)), + "participant_ids": sorted( + instance.participants.values_list("id", flat=True) + ), } task = serializer.save() current = { @@ -494,6 +640,9 @@ class TaskViewSet(viewsets.ModelViewSet): "priority": task.priority, "due_date": task.due_date.isoformat() if task.due_date else None, "label_ids": sorted(task.labels.values_list("id", flat=True)), + "participant_ids": sorted( + task.participants.values_list("id", flat=True) + ), } changed_fields = [ field for field, value in current.items() if previous[field] != value @@ -511,6 +660,7 @@ class TaskViewSet(viewsets.ModelViewSet): def perform_destroy(self, instance): board = instance.board task_id = instance.id + notify_task_deleted(instance, self.request.user) instance.delete() send_task_board_update(board, "deleted", "task", task_id) @@ -621,6 +771,7 @@ class TaskViewSet(viewsets.ModelViewSet): task.assignee = request.user task.save(update_fields=["assignee", "updated_at"]) + task.participants.add(request.user) create_task_activity( task, request.user, diff --git a/backend/notifications/api/serializers.py b/backend/notifications/api/serializers.py index d0a71aa0..404f5259 100644 --- a/backend/notifications/api/serializers.py +++ b/backend/notifications/api/serializers.py @@ -51,6 +51,11 @@ class UnreadSummaryProcurementRequestSerializer(serializers.Serializer): unread = serializers.IntegerField() +class UnreadSummaryTaskBoardSerializer(serializers.Serializer): + board_id = serializers.IntegerField() + unread = serializers.IntegerField() + + class UnreadSummaryRegulationDepartmentSerializer(serializers.Serializer): department_id = serializers.IntegerField() unread = serializers.IntegerField() @@ -60,6 +65,7 @@ class UnreadSummaryResponseSerializer(serializers.Serializer): total = serializers.IntegerField() verbs = UnreadSummaryVerbSerializer(many=True) procurement_requests = UnreadSummaryProcurementRequestSerializer(many=True) + task_boards = UnreadSummaryTaskBoardSerializer(many=True) regulation_departments = UnreadSummaryRegulationDepartmentSerializer( many=True, ) diff --git a/backend/notifications/cache.py b/backend/notifications/cache.py index 620c788e..9a29138a 100644 --- a/backend/notifications/cache.py +++ b/backend/notifications/cache.py @@ -72,6 +72,76 @@ def _build_procurement_request_counts(user_id): ] +def _build_task_board_counts(user_id): + from django.contrib.contenttypes.models import ContentType + + from tasks.models import Task, TaskBoard + + from .models import Notification + + task_content_type_id = ContentType.objects.get_for_model(Task).id + board_content_type_id = ContentType.objects.get_for_model(TaskBoard).id + counts = {} + unresolved_task_ids = set() + unresolved_notifications = [] + notifications = Notification.objects.filter( + recipient_id=user_id, + unread=True, + deleted=False, + verb__startswith="task_", + ).only( + "data", + "target_content_type_id", + "target_object_id", + "action_object_content_type_id", + "action_object_object_id", + ) + + for notification in notifications: + data = notification.data if isinstance(notification.data, dict) else {} + board_id = _coerce_positive_int(data.get("board_id")) + + if board_id is None and ( + notification.action_object_content_type_id == board_content_type_id + ): + board_id = _coerce_positive_int( + notification.action_object_object_id, + ) + + if board_id is not None: + counts[board_id] = counts.get(board_id, 0) + 1 + continue + + task_id = None + if notification.target_content_type_id == task_content_type_id: + task_id = _coerce_positive_int(notification.target_object_id) + elif notification.action_object_content_type_id == task_content_type_id: + task_id = _coerce_positive_int( + notification.action_object_object_id, + ) + + if task_id is not None: + unresolved_task_ids.add(task_id) + unresolved_notifications.append(task_id) + + if unresolved_task_ids: + task_boards = dict( + Task.objects.filter(id__in=unresolved_task_ids).values_list( + "id", + "board_id", + ) + ) + for task_id in unresolved_notifications: + board_id = task_boards.get(task_id) + if board_id is not None: + counts[board_id] = counts.get(board_id, 0) + 1 + + return [ + {"board_id": board_id, "unread": unread} + for board_id, unread in sorted(counts.items()) + ] + + def _build_regulation_counts(user_id): """Группирует непрочитанные уведомления о регламентах по отделам.""" from .models import Notification @@ -139,6 +209,7 @@ def build_unread_summary(user_id): "total": sum(item["unread"] for item in verbs), "verbs": verbs, "procurement_requests": _build_procurement_request_counts(user_id), + "task_boards": _build_task_board_counts(user_id), "regulation_departments": regulation_counts["departments"], "regulation_company_unread": regulation_counts["company_unread"], "regulation_personal_unread": regulation_counts["personal_unread"], diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index 2f1f281f..bab896cd 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -68,7 +68,7 @@ class TaskAdmin(admin.ModelAdmin): ] list_filter = ["board", "column", "priority", "due_date"] search_fields = ["title", "description", "assignee__email"] - filter_horizontal = ["labels"] + filter_horizontal = ["labels", "participants"] @admin.register(TaskAttachment) diff --git a/backend/tasks/migrations/0019_task_participants.py b/backend/tasks/migrations/0019_task_participants.py new file mode 100644 index 00000000..bcad4b2b --- /dev/null +++ b/backend/tasks/migrations/0019_task_participants.py @@ -0,0 +1,84 @@ +# Generated by Django 5.2.4 on 2026-08-05 05:23 + +from django.conf import settings +from django.db import migrations, models + + +def populate_task_participants(apps, schema_editor): + Task = apps.get_model("tasks", "Task") + TaskActivity = apps.get_model("tasks", "TaskActivity") + TaskAttachment = apps.get_model("tasks", "TaskAttachment") + TaskChecklistItem = apps.get_model("tasks", "TaskChecklistItem") + TaskExternalLink = apps.get_model("tasks", "TaskExternalLink") + TaskLinkedObject = apps.get_model("tasks", "TaskLinkedObject") + through = Task.participants.through + task_field = next( + field + for field in through._meta.fields + if field.is_relation and field.related_model == Task + ) + employee_field = next( + field + for field in through._meta.fields + if field.is_relation and field.related_model != Task + ) + + pairs = set() + for task_id, created_by_id, assignee_id in Task.objects.values_list( + "id", + "created_by_id", + "assignee_id", + ): + pairs.add((task_id, created_by_id)) + if assignee_id: + pairs.add((task_id, assignee_id)) + + contributor_fields = [ + (TaskActivity, "actor_id"), + (TaskAttachment, "uploaded_by_id"), + (TaskChecklistItem, "created_by_id"), + (TaskChecklistItem, "completed_by_id"), + (TaskExternalLink, "created_by_id"), + (TaskLinkedObject, "created_by_id"), + ] + for model, employee_id_field in contributor_fields: + pairs.update( + (task_id, employee_id) + for task_id, employee_id in model.objects.exclude( + **{f"{employee_id_field}__isnull": True} + ).values_list("task_id", employee_id_field) + ) + + through.objects.bulk_create( + [ + through( + **{ + task_field.attname: task_id, + employee_field.attname: employee_id, + } + ) + for task_id, employee_id in pairs + if task_id and employee_id + ], + ignore_conflicts=True, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0018_task_claim_activity'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='task', + name='participants', + field=models.ManyToManyField(blank=True, related_name='participating_tasks', to=settings.AUTH_USER_MODEL, verbose_name='Участники'), + ), + migrations.RunPython( + populate_task_participants, + migrations.RunPython.noop, + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index c9f088d0..e62602fe 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -169,6 +169,12 @@ class Task(models.Model): related_name="assigned_tasks", verbose_name="Исполнитель", ) + participants = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="participating_tasks", + verbose_name="Участники", + ) labels = models.ManyToManyField( TaskLabel, blank=True, diff --git a/backend/tasks/notifications/__init__.py b/backend/tasks/notifications/__init__.py index c847087f..e6d2e14e 100644 --- a/backend/tasks/notifications/__init__.py +++ b/backend/tasks/notifications/__init__.py @@ -3,6 +3,8 @@ from .handlers import ( notify_board_members_added, notify_task_comment, notify_task_created, + notify_task_deleted, + notify_task_activity_change, notify_task_linked_object, notify_task_moved, notify_task_updated, @@ -15,6 +17,8 @@ __all__ = [ "notify_board_members_added", "notify_task_comment", "notify_task_created", + "notify_task_deleted", + "notify_task_activity_change", "notify_task_linked_object", "notify_task_moved", "notify_task_updated", diff --git a/backend/tasks/notifications/config.py b/backend/tasks/notifications/config.py index 67941055..6b9dc0d4 100644 --- a/backend/tasks/notifications/config.py +++ b/backend/tasks/notifications/config.py @@ -1,4 +1,5 @@ class NotificationVerbs: + TASK_CREATED = "task_created" TASK_ASSIGNED = "task_assigned" TASK_REASSIGNED = "task_reassigned" TASK_COMMENT = "task_comment" @@ -7,6 +8,14 @@ class NotificationVerbs: TASK_OVERDUE = "task_overdue" TASK_COMPLETED = "task_completed" TASK_REOPENED = "task_reopened" + TASK_UPDATED = "task_updated" + TASK_MOVED = "task_moved" + TASK_DELETED = "task_deleted" + TASK_ATTACHMENT_ADDED = "task_attachment_added" + TASK_ATTACHMENT_REMOVED = "task_attachment_removed" + TASK_CHECKLIST_CHANGED = "task_checklist_changed" + TASK_LINKED_OBJECT_REMOVED = "task_linked_object_removed" + TASK_COMMENT_CHANGED = "task_comment_changed" TASK_LINKED_OBJECT_ADDED = "task_linked_object_added" TASK_BOARD_MEMBER_ADDED = "task_board_member_added" @@ -22,6 +31,13 @@ class ActionURLs: class MessageTemplates: + @staticmethod + def task_created(task, actor_name: str) -> tuple[str, str]: + return ( + "Новая задача", + f'{actor_name} создал задачу #{task.id} "{task.title}".', + ) + @staticmethod def task_assigned(task, actor_name: str) -> tuple[str, str]: return ( @@ -89,6 +105,99 @@ class MessageTemplates: f'{actor_name} вернул в работу задачу #{task.id} "{task.title}".', ) + @staticmethod + def task_moved(task, actor_name: str, old_column, new_column) -> tuple[str, str]: + return ( + "Задача перемещена", + ( + f'{actor_name} переместил задачу #{task.id} "{task.title}": ' + f'"{old_column.name}" -> "{new_column.name}".' + ), + ) + + @staticmethod + def task_updated(task, actor_name: str, fields: list[str]) -> tuple[str, str]: + field_names = { + "title": "название", + "description": "описание", + "priority": "приоритет", + "label_ids": "метки", + "participant_ids": "участников", + } + changed = ", ".join(field_names.get(field, field) for field in fields) + return ( + "Задача изменена", + ( + f'{actor_name} изменил задачу #{task.id} "{task.title}"' + f'{f": {changed}" if changed else ""}.' + ), + ) + + @staticmethod + def task_deleted(task, actor_name: str) -> tuple[str, str]: + return ( + "Задача удалена", + f'{actor_name} удалил задачу #{task.id} "{task.title}".', + ) + + @staticmethod + def task_activity_changed( + task, + actor_name: str, + action: str, + metadata: dict, + ) -> tuple[str, str]: + file_name = metadata.get("file_name") or "файл" + item_title = metadata.get("item_title") or "пункт" + object_label = metadata.get("object_label") or metadata.get("object_type") or "объект" + messages = { + "attachment_added": ( + "К задаче добавлен файл", + f'{actor_name} добавил файл "{file_name}" к задаче #{task.id} "{task.title}".', + ), + "attachment_removed": ( + "Из задачи удален файл", + f'{actor_name} удалил файл "{file_name}" из задачи #{task.id} "{task.title}".', + ), + "checklist_item_added": ( + "Чек-лист задачи изменен", + f'{actor_name} добавил пункт "{item_title}" в задачу #{task.id} "{task.title}".', + ), + "checklist_item_updated": ( + "Чек-лист задачи изменен", + f'{actor_name} изменил пункт "{item_title}" в задаче #{task.id} "{task.title}".', + ), + "checklist_item_completed": ( + "Пункт чек-листа выполнен", + f'{actor_name} выполнил пункт "{item_title}" в задаче #{task.id} "{task.title}".', + ), + "checklist_item_reopened": ( + "Пункт чек-листа возвращен", + f'{actor_name} вернул пункт "{item_title}" в задаче #{task.id} "{task.title}".', + ), + "checklist_item_removed": ( + "Чек-лист задачи изменен", + f'{actor_name} удалил пункт "{item_title}" из задачи #{task.id} "{task.title}".', + ), + "linked": ( + "К задаче добавлена ссылка", + f'{actor_name} добавил "{object_label}" к задаче #{task.id} "{task.title}".', + ), + "unlinked": ( + "Связь с задачей удалена", + f'{actor_name} удалил связь "{object_label}" из задачи #{task.id} "{task.title}".', + ), + "comment_edited": ( + "Комментарий к задаче изменен", + f'{actor_name} изменил комментарий в задаче #{task.id} "{task.title}".', + ), + "comment_removed": ( + "Комментарий к задаче удален", + f'{actor_name} удалил комментарий из задачи #{task.id} "{task.title}".', + ), + } + return messages[action] + @staticmethod def task_linked_object_added( task, diff --git a/backend/tasks/notifications/handlers.py b/backend/tasks/notifications/handlers.py index 0b4fdc10..ee3d0b5a 100644 --- a/backend/tasks/notifications/handlers.py +++ b/backend/tasks/notifications/handlers.py @@ -8,7 +8,13 @@ from django.utils import timezone from notifications.models import Notification from notifications.signals import notify -from tasks.models import Task, TaskLinkedObject, TaskLinkedObjectKind +from tasks.models import ( + Task, + TaskActivityAction, + TaskActivityObjectKind, + TaskLinkedObject, + TaskLinkedObjectKind, +) from .config import ActionURLs, MessageTemplates, NotificationVerbs @@ -50,6 +56,7 @@ def _send_task_notification( actor=None, action_object=None, extra_data: dict | None = None, + action_url: str | None = None, ): if not recipient or not getattr(recipient, "is_active", False): return @@ -63,7 +70,7 @@ def _send_task_notification( action_object=action_object or task, target=task, description=description, - action_url=ActionURLs.task_detail(task), + action_url=action_url or ActionURLs.task_detail(task), data=_task_data(task, title, extra_data), ) @@ -78,6 +85,7 @@ def _notify_many( actor=None, action_object=None, extra_data: dict | None = None, + action_url: str | None = None, ): seen = set() for recipient in recipients: @@ -93,6 +101,7 @@ def _notify_many( actor=actor, action_object=action_object, extra_data=extra_data, + action_url=action_url, ) @@ -114,7 +123,7 @@ def _task_recipient_ids( include_linked_employees: bool = True, include_commenters: bool = False, ) -> set[int]: - recipient_ids = set() + recipient_ids = set(task.participants.values_list("id", flat=True)) if include_created_by and task.created_by_id: recipient_ids.add(task.created_by_id) if include_assignee and task.assignee_id: @@ -140,18 +149,34 @@ def _task_recipients(task: Task, **kwargs): def notify_task_created(task: Task, actor=None): - if not task.assignee_id: - return - title, description = MessageTemplates.task_assigned(task, _actor_name(actor)) - _send_task_notification( - recipient=task.assignee, - verb=NotificationVerbs.TASK_ASSIGNED, + recipient_ids = _task_recipient_ids(task) + if task.assignee_id: + recipient_ids.discard(task.assignee_id) + + title, description = MessageTemplates.task_created(task, _actor_name(actor)) + _notify_many( + User.objects.filter(id__in=recipient_ids, is_active=True), + verb=NotificationVerbs.TASK_CREATED, task=task, title=title, description=description, actor=actor, extra_data={"assignee_id": task.assignee_id}, ) + if task.assignee_id: + title, description = MessageTemplates.task_assigned( + task, + _actor_name(actor), + ) + _send_task_notification( + recipient=task.assignee, + verb=NotificationVerbs.TASK_ASSIGNED, + task=task, + title=title, + description=description, + actor=actor, + extra_data={"assignee_id": task.assignee_id}, + ) def notify_task_updated(task: Task, actor, previous: dict, current: dict): @@ -163,13 +188,11 @@ def notify_task_updated(task: Task, actor, previous: dict, current: dict): if previous_assignee_id is None else MessageTemplates.task_reassigned(task, _actor_name(actor)) ) - recipients = [] - if current_assignee_id: - recipients.extend(User.objects.filter(id=current_assignee_id)) + recipient_ids = _task_recipient_ids(task, include_commenters=True) if previous_assignee_id: - recipients.extend(User.objects.filter(id=previous_assignee_id)) + recipient_ids.add(previous_assignee_id) _notify_many( - recipients, + User.objects.filter(id__in=recipient_ids, is_active=True), verb=( NotificationVerbs.TASK_ASSIGNED if previous_assignee_id is None @@ -205,6 +228,43 @@ def notify_task_updated(task: Task, actor, previous: dict, current: dict): }, ) + participant_ids_changed = ( + previous.get("participant_ids") != current.get("participant_ids") + ) + if participant_ids_changed and previous_assignee_id != current_assignee_id: + previous_participants = set(previous.get("participant_ids") or []) + current_participants = set(current.get("participant_ids") or []) + for assignee_id in {previous_assignee_id, current_assignee_id}: + if assignee_id: + previous_participants.discard(assignee_id) + current_participants.discard(assignee_id) + participant_ids_changed = previous_participants != current_participants + + generic_fields = [ + field + for field in ("title", "description", "priority", "label_ids") + if previous.get(field) != current.get(field) + ] + if participant_ids_changed: + generic_fields.append("participant_ids") + if generic_fields: + title, description = MessageTemplates.task_updated( + task, + _actor_name(actor), + generic_fields, + ) + recipient_ids = _task_recipient_ids(task, include_commenters=True) + recipient_ids.update(previous.get("participant_ids") or []) + _notify_many( + User.objects.filter(id__in=recipient_ids, is_active=True), + verb=NotificationVerbs.TASK_UPDATED, + task=task, + title=title, + description=description, + actor=actor, + extra_data={"fields": generic_fields}, + ) + def notify_task_moved(task: Task, actor, old_column, new_column): if old_column.id == new_column.id: @@ -222,7 +282,13 @@ def notify_task_moved(task: Task, actor, old_column, new_column): ) verb = NotificationVerbs.TASK_REOPENED else: - return + title, description = MessageTemplates.task_moved( + task, + _actor_name(actor), + old_column, + new_column, + ) + verb = NotificationVerbs.TASK_MOVED _notify_many( _task_recipients(task, include_commenters=True), @@ -240,6 +306,23 @@ def notify_task_moved(task: Task, actor, old_column, new_column): ) +def notify_task_deleted(task: Task, actor): + title, description = MessageTemplates.task_deleted( + task, + _actor_name(actor), + ) + _notify_many( + _task_recipients(task, include_commenters=True), + verb=NotificationVerbs.TASK_DELETED, + task=task, + title=title, + description=description, + actor=actor, + extra_data={"deleted": True}, + action_url=ActionURLs.board_detail(task.board), + ) + + def notify_task_comment(task: Task, message): actor = message.author title, description = MessageTemplates.task_comment( @@ -289,6 +372,68 @@ def notify_task_linked_object(link: TaskLinkedObject): ) +def notify_task_activity_change( + task: Task, + actor, + action: str, + *, + object_kind: str = "", + metadata: dict | None = None, +): + metadata = metadata or {} + if action in { + TaskActivityAction.ATTACHMENT_ADDED, + TaskActivityAction.ATTACHMENT_REMOVED, + }: + verb = ( + NotificationVerbs.TASK_ATTACHMENT_ADDED + if action == TaskActivityAction.ATTACHMENT_ADDED + else NotificationVerbs.TASK_ATTACHMENT_REMOVED + ) + elif action in { + TaskActivityAction.CHECKLIST_ITEM_ADDED, + TaskActivityAction.CHECKLIST_ITEM_UPDATED, + TaskActivityAction.CHECKLIST_ITEM_COMPLETED, + TaskActivityAction.CHECKLIST_ITEM_REOPENED, + TaskActivityAction.CHECKLIST_ITEM_REMOVED, + }: + verb = NotificationVerbs.TASK_CHECKLIST_CHANGED + elif action == TaskActivityAction.UNLINKED: + verb = NotificationVerbs.TASK_LINKED_OBJECT_REMOVED + elif ( + action == TaskActivityAction.LINKED + and object_kind == TaskActivityObjectKind.EXTERNAL_LINK + ): + verb = NotificationVerbs.TASK_LINKED_OBJECT_ADDED + elif action in { + TaskActivityAction.COMMENT_EDITED, + TaskActivityAction.COMMENT_REMOVED, + }: + verb = NotificationVerbs.TASK_COMMENT_CHANGED + else: + return + + title, description = MessageTemplates.task_activity_changed( + task, + _actor_name(actor), + action, + metadata, + ) + _notify_many( + _task_recipients(task, include_commenters=True), + verb=verb, + task=task, + title=title, + description=description, + actor=actor, + extra_data={ + "activity_action": action, + "object_kind": object_kind, + **metadata, + }, + ) + + def notify_board_members_added(board, actor, members): title, description = MessageTemplates.board_member_added( board, @@ -338,7 +483,6 @@ def dispatch_task_due_notifications() -> dict[str, int]: queryset = ( Task.objects.filter( due_date__isnull=False, - assignee__isnull=False, completed_at__isnull=True, column__is_done=False, board__is_archived=False, @@ -356,21 +500,26 @@ def dispatch_task_due_notifications() -> dict[str, int]: else: continue - if _already_sent_today( - recipient_id=task.assignee_id, - verb=verb, - task=task, - ): + sent = False + for recipient in _task_recipients(task, include_commenters=True): + if _already_sent_today( + recipient_id=recipient.id, + verb=verb, + task=task, + ): + continue + _send_task_notification( + recipient=recipient, + verb=verb, + task=task, + title=title, + description=description, + actor=None, + extra_data={"due_date": task.due_date.isoformat()}, + ) + sent = True + if not sent: continue - _send_task_notification( - recipient=task.assignee, - verb=verb, - task=task, - title=title, - description=description, - actor=None, - extra_data={"due_date": task.due_date.isoformat()}, - ) if verb == NotificationVerbs.TASK_OVERDUE: overdue_count += 1 else: diff --git a/backend/tasks/notifications/read_service.py b/backend/tasks/notifications/read_service.py new file mode 100644 index 00000000..59d048b4 --- /dev/null +++ b/backend/tasks/notifications/read_service.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass + +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from django.db.models import Q +from django.utils import timezone + +from notifications.cache import invalidate_unread_summary +from notifications.models import Notification +from notifications.realtime import send_notifications_read_all_event +from tasks.models import Task, TaskBoard + + +@dataclass(frozen=True) +class TaskBoardNotificationReadResult: + notification_ids: list[int] + count: int + + +class TaskBoardNotificationReadService: + """Marks only notifications related to one task board as read.""" + + def mark_board_notifications_as_read( + self, + *, + user, + board: TaskBoard, + ) -> TaskBoardNotificationReadResult: + queryset = self._get_unread_queryset(user_id=user.id, board=board) + notification_ids = list(queryset.values_list("id", flat=True)) + if not notification_ids: + return TaskBoardNotificationReadResult([], 0) + + count = Notification.objects.filter(id__in=notification_ids).update( + unread=False, + timestamp_read=timezone.now(), + ) + invalidate_unread_summary(user.id) + + def notify_after_commit(): + invalidate_unread_summary(user.id) + send_notifications_read_all_event(user.id, notification_ids) + + transaction.on_commit(notify_after_commit) + return TaskBoardNotificationReadResult(notification_ids, count) + + def _get_unread_queryset(self, *, user_id: int, board: TaskBoard): + task_content_type = ContentType.objects.get_for_model(Task) + board_content_type = ContentType.objects.get_for_model(TaskBoard) + board_id = board.id + board_id_text = str(board_id) + task_ids = [ + str(task_id) + for task_id in board.tasks.values_list("id", flat=True) + ] + + board_filter = ( + Q(data__board_id=board_id) + | Q(data__board_id=board_id_text) + | Q( + action_object_content_type=board_content_type, + action_object_object_id=board_id_text, + ) + ) + if task_ids: + board_filter |= Q( + target_content_type=task_content_type, + target_object_id__in=task_ids, + ) | Q( + action_object_content_type=task_content_type, + action_object_object_id__in=task_ids, + ) + + return ( + Notification.objects.filter( + recipient_id=user_id, + unread=True, + deleted=False, + verb__startswith="task_", + ) + .filter(board_filter) + .distinct() + ) diff --git a/backend/tasks/signals.py b/backend/tasks/signals.py index fbd7199a..10ee43c0 100644 --- a/backend/tasks/signals.py +++ b/backend/tasks/signals.py @@ -1,8 +1,16 @@ from django.db import transaction -from django.db.models.signals import post_delete +from django.db.models.signals import post_delete, post_save from django.dispatch import receiver -from .models import TaskAttachment +from .models import Task, TaskAttachment + + +@receiver(post_save, sender=Task) +def ensure_task_core_participants(sender, instance, **kwargs): + participant_ids = [instance.created_by_id] + if instance.assignee_id: + participant_ids.append(instance.assignee_id) + instance.participants.add(*participant_ids) @receiver(post_delete, sender=TaskAttachment) diff --git a/backend/tests/api/v1/notifications/test_notifications_api.py b/backend/tests/api/v1/notifications/test_notifications_api.py index 59f711d0..d718e4f4 100644 --- a/backend/tests/api/v1/notifications/test_notifications_api.py +++ b/backend/tests/api/v1/notifications/test_notifications_api.py @@ -35,6 +35,24 @@ class TestNotificationUnreadSummary: verb="chat_new_message", description="Сообщение", ) + Notification.objects.create( + recipient=user, + verb="task_assigned", + description="Задача на первой доске", + data={"board_id": 21}, + ) + Notification.objects.create( + recipient=user, + verb="task_comment", + description="Еще одна задача на первой доске", + data={"board_id": "21"}, + ) + Notification.objects.create( + recipient=user, + verb="task_board_member_added", + description="Добавление на вторую доску", + data={"board_id": 22}, + ) Notification.objects.create( recipient=user, verb="regulation_ready", @@ -74,13 +92,16 @@ class TestNotificationUnreadSummary: response = api_client.get("/api/v1/notifications/summary/") assert response.status_code == 200 - assert response.data["total"] == 6 + assert response.data["total"] == 9 assert { item["verb"]: item["unread"] for item in response.data["verbs"] } == { "procurement_department_request": 2, "chat_new_message": 1, + "task_assigned": 1, + "task_comment": 1, + "task_board_member_added": 1, "regulation_ready": 2, "regulation_signed_all": 1, } @@ -88,6 +109,10 @@ class TestNotificationUnreadSummary: {"request_id": 10, "unread": 1}, {"request_id": 12, "unread": 1}, ] + assert response.data["task_boards"] == [ + {"board_id": 21, "unread": 2}, + {"board_id": 22, "unread": 1}, + ] assert response.data["regulation_departments"] == [ {"department_id": 7, "unread": 1}, {"department_id": 8, "unread": 1}, diff --git a/backend/tests/api/v1/tasks/test_task_notifications.py b/backend/tests/api/v1/tasks/test_task_notifications.py index 349fdb0a..e5538f7c 100644 --- a/backend/tests/api/v1/tasks/test_task_notifications.py +++ b/backend/tests/api/v1/tasks/test_task_notifications.py @@ -2,6 +2,7 @@ from datetime import timedelta import pytest from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from django.utils import timezone from rest_framework import status @@ -107,7 +108,10 @@ def test_reassign_task_notifies_old_and_new_assignees(api_client): response = api_client.patch( reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}), - {"assignee_id": new_assignee.id}, + { + "assignee_id": new_assignee.id, + "participant_ids": [], + }, format="json", ) @@ -118,6 +122,16 @@ def test_reassign_task_notifies_old_and_new_assignees(api_client): ).values_list("recipient_id", flat=True) ) assert recipients == {old_assignee.id, new_assignee.id} + task.refresh_from_db() + assert set(task.participants.values_list("id", flat=True)) == { + owner.id, + old_assignee.id, + new_assignee.id, + } + assert not Notification.objects.filter( + verb=NotificationVerbs.TASK_UPDATED, + data__task_id=task.id, + ).exists() def test_task_comment_notifies_connected_users_except_author(api_client): @@ -222,3 +236,161 @@ def test_due_notification_dispatches_once_per_day(): recipient=assignee, verb=NotificationVerbs.TASK_OVERDUE, ).count() == 1 + assert Notification.objects.filter( + recipient=owner, + verb=NotificationVerbs.TASK_DUE_SOON, + ).count() == 1 + assert Notification.objects.filter( + recipient=owner, + verb=NotificationVerbs.TASK_OVERDUE, + ).count() == 1 + + +def test_task_participants_receive_file_and_move_notifications( + api_client, + tmp_path, + settings, +): + settings.MEDIA_ROOT = tmp_path + owner = make_user("task-participant-owner@example.com", "+79995550014") + assignee = make_user("task-participant-assignee@example.com", "+79995550015") + watcher = make_user("task-participant-watcher@example.com", "+79995550016") + contributor = make_user("task-participant-contributor@example.com", "+79995550017") + board, source_column, _done = make_board(owner) + target_column = TaskColumn.objects.create( + board=board, + name="В работе", + position=1500, + ) + api_client.force_authenticate(user=owner) + + create_response = api_client.post( + reverse("api:v1:tasks:task-list"), + { + "board": board.id, + "column": source_column.id, + "title": "Задача с участниками", + "assignee_id": assignee.id, + "participant_ids": [watcher.id], + }, + format="json", + ) + + assert create_response.status_code == status.HTTP_201_CREATED + task = Task.objects.get(id=create_response.data["id"]) + assert set(task.participants.values_list("id", flat=True)) == { + owner.id, + assignee.id, + watcher.id, + } + assert Notification.objects.filter( + recipient=assignee, + verb=NotificationVerbs.TASK_ASSIGNED, + data__task_id=task.id, + ).exists() + assert Notification.objects.filter( + recipient=watcher, + verb=NotificationVerbs.TASK_CREATED, + data__task_id=task.id, + ).exists() + + Notification.objects.all().delete() + api_client.force_authenticate(user=contributor) + attachment_response = api_client.post( + reverse("api:v1:tasks:task-attachments", kwargs={"pk": task.id}), + { + "files": SimpleUploadedFile( + "details.zip", + b"archive-content", + content_type="application/zip", + ) + }, + format="multipart", + ) + + assert attachment_response.status_code == status.HTTP_201_CREATED + assert task.participants.filter(id=contributor.id).exists() + attachment_recipients = set( + Notification.objects.filter( + verb=NotificationVerbs.TASK_ATTACHMENT_ADDED, + data__task_id=task.id, + ).values_list("recipient_id", flat=True) + ) + assert attachment_recipients == {owner.id, assignee.id, watcher.id} + + Notification.objects.all().delete() + move_response = api_client.post( + reverse("api:v1:tasks:task-move", kwargs={"pk": task.id}), + {"column": target_column.id}, + format="json", + ) + + assert move_response.status_code == status.HTTP_200_OK + moved_recipients = set( + Notification.objects.filter( + verb=NotificationVerbs.TASK_MOVED, + data__task_id=task.id, + ).values_list("recipient_id", flat=True) + ) + assert moved_recipients == {owner.id, assignee.id, watcher.id} + + +def test_due_notification_reaches_creator_without_assignee(): + owner = make_user("task-due-owner-only@example.com", "+79995550023") + _board, column, _done = make_board(owner) + task = make_task( + owner, + column, + title="Срок задачи без исполнителя", + due_date=timezone.localdate(), + ) + + result = dispatch_task_due_notifications() + + assert result == {"due_soon": 1, "overdue": 0} + assert Notification.objects.filter( + recipient=owner, + verb=NotificationVerbs.TASK_DUE_SOON, + data__task_id=task.id, + ).exists() + + +def test_task_creator_and_assignee_are_automatic_participants(): + owner = make_user("task-auto-owner@example.com", "+79995550018") + assignee = make_user("task-auto-assignee@example.com", "+79995550019") + _board, column, _done = make_board(owner) + + task = make_task(owner, column, assignee=assignee) + + assert set(task.participants.values_list("id", flat=True)) == { + owner.id, + assignee.id, + } + + +def test_delete_task_notifies_participants_and_links_to_board(api_client): + owner = make_user("task-delete-owner@example.com", "+79995550020") + assignee = make_user("task-delete-assignee@example.com", "+79995550021") + participant = make_user("task-delete-participant@example.com", "+79995550022") + board, column, _done = make_board(owner) + task = make_task(owner, column, assignee=assignee) + task.participants.add(participant) + task_id = task.id + api_client.force_authenticate(user=owner) + + response = api_client.delete( + reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}) + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + notifications = Notification.objects.filter( + verb=NotificationVerbs.TASK_DELETED, + data__task_id=task_id, + ) + assert set(notifications.values_list("recipient_id", flat=True)) == { + assignee.id, + participant.id, + } + assert set(notifications.values_list("action_url", flat=True)) == { + f"/tasks?board={board.id}" + } diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 73782908..796e158f 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -18,6 +18,7 @@ from feed.models import Post from guests.models import Guest, GuestVisit from procurement.constants import ProcurementStatus, UrgencyLevel from procurement.models import ProcurementRequest +from notifications.models import Notification from requests_app.models import Request as EmployeeRequest from schedule.models import Calendar, CalendarRelation, Event from tasks.models import ( @@ -142,6 +143,228 @@ def test_user_cannot_set_inaccessible_board_as_default(api_client): assert not TaskUserSettings.objects.filter(user=outsider).exists() +def test_opening_board_marks_only_its_notifications_as_read( + api_client, + user, + django_capture_on_commit_callbacks, +): + first_board = TaskBoard.objects.create(name="Первая", created_by=user) + second_board = TaskBoard.objects.create(name="Вторая", created_by=user) + first_task = Task.objects.create( + board=first_board, + column=TaskColumn.objects.create( + board=first_board, + name="Новые", + position=1000, + ), + title="Первая задача", + created_by=user, + ) + TaskColumn.objects.create( + board=second_board, + name="Новые", + position=1000, + ) + first_notification = Notification.objects.create( + recipient=user, + verb="task_comment", + description="Первая доска", + target=first_task, + data={"board_id": first_board.id}, + ) + second_notification = Notification.objects.create( + recipient=user, + verb="task_assigned", + description="Вторая доска", + data={"board_id": second_board.id}, + ) + other_notification = Notification.objects.create( + recipient=user, + verb="request_new", + description="Заявление", + ) + + api_client.force_authenticate(user=user) + with patch( + "tasks.notifications.read_service.send_notifications_read_all_event" + ) as send_event: + with django_capture_on_commit_callbacks(execute=True): + response = api_client.post( + reverse( + "api:v1:tasks:task-board-mark-notifications-read", + kwargs={"pk": first_board.id}, + ) + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 1 + assert response.data["notification_ids"] == [first_notification.id] + first_notification.refresh_from_db() + second_notification.refresh_from_db() + other_notification.refresh_from_db() + assert first_notification.unread is False + assert second_notification.unread is True + assert other_notification.unread is True + send_event.assert_called_once_with(user.id, [first_notification.id]) + + +def test_board_member_reorders_columns_for_everyone(api_client, user): + other_user = make_user("task-column-order@example.com", "+79994440093") + board = TaskBoard.objects.create(name="Общий порядок", created_by=user) + board.members.add(user, other_user) + first = TaskColumn.objects.create( + board=board, + name="Первая", + position=1000, + ) + second = TaskColumn.objects.create( + board=board, + name="Вторая", + position=2000, + ) + third = TaskColumn.objects.create( + board=board, + name="Третья", + position=3000, + ) + + api_client.force_authenticate(user=other_user) + with patch("api.v1.tasks.views.send_task_board_update") as send_update: + response = api_client.post( + reverse("api:v1:tasks:task-column-reorder"), + { + "board": board.id, + "column_ids": [third.id, first.id, second.id], + }, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["column_ids"] == [third.id, first.id, second.id] + assert list( + TaskColumn.objects.filter(board=board).values_list("id", flat=True) + ) == [third.id, first.id, second.id] + send_update.assert_called_once_with( + board, + "reordered", + "column", + extra={"column_ids": [third.id, first.id, second.id]}, + ) + + api_client.force_authenticate(user=user) + 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 + assert [column["id"] for column in board_response.data["columns"]] == [ + third.id, + first.id, + second.id, + ] + + +def test_reorder_columns_requires_complete_board_column_list(api_client, user): + board = TaskBoard.objects.create(name="Проверка порядка", created_by=user) + first = TaskColumn.objects.create( + board=board, + name="Первая", + position=1000, + ) + second = TaskColumn.objects.create( + board=board, + name="Вторая", + position=2000, + ) + + api_client.force_authenticate(user=user) + response = api_client.post( + reverse("api:v1:tasks:task-column-reorder"), + {"board": board.id, "column_ids": [second.id]}, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "column_ids" in response.data + assert list( + TaskColumn.objects.filter(board=board).values_list("id", flat=True) + ) == [first.id, second.id] + + +def test_user_can_edit_and_delete_empty_task_column(api_client, user): + board = TaskBoard.objects.create(name="Управление колонками", created_by=user) + TaskColumn.objects.create(board=board, name="Основная", position=1000) + editable = TaskColumn.objects.create( + board=board, + name="Изменяемая", + position=2000, + ) + api_client.force_authenticate(user=user) + + update_response = api_client.patch( + reverse("api:v1:tasks:task-column-detail", kwargs={"pk": editable.id}), + { + "name": "Обновлённая", + "color": "#ef4444", + "is_done": True, + }, + format="json", + ) + delete_response = api_client.delete( + reverse("api:v1:tasks:task-column-detail", kwargs={"pk": editable.id}) + ) + + assert update_response.status_code == status.HTTP_200_OK + assert update_response.data["name"] == "Обновлённая" + assert update_response.data["color"] == "#ef4444" + assert update_response.data["is_done"] is True + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + assert not TaskColumn.objects.filter(id=editable.id).exists() + + +def test_user_cannot_delete_occupied_or_last_task_column(api_client, user): + board = TaskBoard.objects.create(name="Защита колонок", created_by=user) + occupied = TaskColumn.objects.create( + board=board, + name="С задачей", + position=1000, + ) + TaskColumn.objects.create( + board=board, + name="Пустая", + position=2000, + ) + Task.objects.create( + board=board, + column=occupied, + title="Защищённая задача", + created_by=user, + ) + api_client.force_authenticate(user=user) + + occupied_response = api_client.delete( + reverse("api:v1:tasks:task-column-detail", kwargs={"pk": occupied.id}) + ) + single_board = TaskBoard.objects.create( + name="Единственная колонка", + created_by=user, + ) + only_column = TaskColumn.objects.create( + board=single_board, + name="Единственная", + position=1000, + ) + last_response = api_client.delete( + reverse("api:v1:tasks:task-column-detail", kwargs={"pk": only_column.id}) + ) + + assert occupied_response.status_code == status.HTTP_400_BAD_REQUEST + assert "Переместите задачи" in occupied_response.data["detail"] + assert last_response.status_code == status.HTTP_400_BAD_REQUEST + assert "хотя бы одна колонка" in last_response.data["detail"] + assert TaskColumn.objects.filter(id=occupied.id).exists() + assert TaskColumn.objects.filter(id=only_column.id).exists() + + def test_create_task_and_move_to_done_column(api_client, user): api_client.force_authenticate(user=user) board_response = api_client.get( @@ -905,6 +1128,32 @@ def test_public_task_board_realtime_recipients_include_active_users(): assert inactive_user.id not in recipients +def test_task_participant_candidates_follow_board_access(api_client): + owner = make_user("task-candidates-owner@example.com", "+79994440097") + member = make_user("task-candidates-member@example.com", "+79994440098") + outsider = make_user("task-candidates-outsider@example.com", "+79994440099") + board = TaskBoard.objects.create( + name="Кандидаты в участники", + created_by=owner, + access_scope="restricted", + ) + board.members.add(member) + api_client.force_authenticate(user=owner) + + response = api_client.get( + reverse( + "api:v1:tasks:task-board-participant-candidates", + kwargs={"pk": board.id}, + ) + ) + + assert response.status_code == status.HTTP_200_OK + candidate_ids = {candidate["id"] for candidate in response.data} + assert owner.id in candidate_ids + assert member.id in candidate_ids + assert outsider.id not in candidate_ids + + def test_task_linked_message_is_visible_by_task_access(api_client): owner = make_user("task-link-owner@example.com", "+79994440019") board_member = make_user("task-link-member@example.com", "+79994440020") diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 15a1498b..2b99ae6d 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -8,12 +8,17 @@ import { PointerSensor, closestCorners, useDraggable, - useDroppable, useSensor, useSensors, type DragEndEvent, type DragStartEvent, } from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + horizontalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { CalendarDays, @@ -43,6 +48,7 @@ import { Star, Tag, Trash2, + UserPlus, UserRound, X, type LucideIcon, @@ -53,6 +59,7 @@ import { AppShell } from "@/components/AppShell"; import { RequestAvatar } from "@/components/requests/RequestAvatar"; import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls"; import { Modal } from "@/components/ui"; +import { useNotifications } from "@/contexts/NotificationsContext"; import { useUser } from "@/contexts/UserContext"; import { apiClient } from "@/lib/api"; import { displayUserName, formatDate, formatDateTime, formatMoney } from "@/lib/shared"; @@ -91,6 +98,7 @@ type TaskFormState = { description: string; column: number | ""; assignee_id: number | ""; + participant_ids: number[]; priority: TaskPriority; due_date: string; label_ids: number[]; @@ -106,6 +114,7 @@ type BoardFormState = { }; type ColumnFormState = { + id: number | null; name: string; color: string; is_done: boolean; @@ -146,6 +155,7 @@ const emptyForm: TaskFormState = { description: "", column: "", assignee_id: "", + participant_ids: [], priority: "medium", due_date: "", label_ids: [], @@ -184,6 +194,7 @@ function getEmployeeInitials(employee: User): string { } const emptyColumnForm: ColumnFormState = { + id: null, name: "", color: "#38bdf8", is_done: false, @@ -337,7 +348,7 @@ function TaskCardView({ }) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: `task-${task.id}`, - data: { taskId: task.id }, + data: { type: "task", taskId: task.id }, }); const style = transform && !isDragging ? { transform: CSS.Translate.toString(transform) } : undefined; const priority = priorityMeta[task.priority] ?? priorityMeta.medium; @@ -607,6 +618,11 @@ function BoardColumn({ openMenuTaskId, menuRef, onToggleTaskMenu, + columnMenuOpen, + columnMenuRef, + onToggleColumnMenu, + onEditColumn, + onDeleteColumn, onColumnMount, fillAvailableHeight = false, }: { @@ -624,13 +640,32 @@ function BoardColumn({ openMenuTaskId: number | null; menuRef: RefObject; onToggleTaskMenu: (taskId: number) => void; + columnMenuOpen: boolean; + columnMenuRef: RefObject; + onToggleColumnMenu: () => void; + onEditColumn: (column: TaskColumn) => void; + onDeleteColumn: (column: TaskColumn) => void; onColumnMount: (columnId: number, node: HTMLElement | null) => void; fillAvailableHeight?: boolean; }) { - const { setNodeRef, isOver } = useDroppable({ + const { + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + isDragging, + isOver, + } = useSortable({ id: `column-${column.id}`, - data: { columnId: column.id }, + data: { type: "column", columnId: column.id }, }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 20 : undefined, + }; const setColumnNodeRef = useCallback((node: HTMLElement | null) => { setNodeRef(node); onColumnMount(column.id, node); @@ -639,12 +674,19 @@ function BoardColumn({ return (
-
+

{tasks.length} задач

- +
+ +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + + {columnMenuOpen ? ( +
+ + +
+ ) : null} +
+
{tasks.length > 0 ? ( @@ -2005,6 +2094,7 @@ export default function TasksPage() { function TasksPageContent() { const { user } = useUser(); + const { unreadTaskBoardCounts, markTaskBoardAsRead } = useNotifications(); const searchParams = useSearchParams(); const requestedBoardId = Number(searchParams.get("board") || ""); const requestedTaskId = Number(searchParams.get("task") || ""); @@ -2012,6 +2102,7 @@ function TasksPageContent() { const [board, setBoard] = useState(null); const [selectedBoardId, setSelectedBoardId] = useState(null); const [employees, setEmployees] = useState([]); + const [taskParticipantCandidates, setTaskParticipantCandidates] = useState([]); const [departments, setDepartments] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -2025,9 +2116,15 @@ function TasksPageContent() { const [activeTaskId, setActiveTaskId] = useState(null); const [viewTaskId, setViewTaskId] = useState(null); const [taskModalOpen, setTaskModalOpen] = useState(false); + const [taskParticipantsOpen, setTaskParticipantsOpen] = useState(false); + const [taskParticipantSearch, setTaskParticipantSearch] = useState(""); + const [participantModalOpen, setParticipantModalOpen] = useState(false); + const [participantModalSearch, setParticipantModalSearch] = useState(""); + const [participantModalSelection, setParticipantModalSelection] = useState([]); const [taskMenuOpen, setTaskMenuOpen] = useState(false); const [viewColumnMenuOpen, setViewColumnMenuOpen] = useState(false); const [cardMenuTaskId, setCardMenuTaskId] = useState(null); + const [columnMenuId, setColumnMenuId] = useState(null); const [boardModalOpen, setBoardModalOpen] = useState(false); const [boardMenuOpen, setBoardMenuOpen] = useState(false); const [boardActionsMenuOpen, setBoardActionsMenuOpen] = useState(false); @@ -2106,6 +2203,7 @@ function TasksPageContent() { const taskMenuRef = useRef(null); const viewColumnMenuRef = useRef(null); const cardMenuRef = useRef(null); + const columnMenuRef = useRef(null); const selectedBoardIdRef = useRef(null); const taskBoardSyncTimerRef = useRef | null>(null); const boardScrollRef = useRef(null); @@ -2115,6 +2213,7 @@ function TasksPageContent() { const taskChecklistDraftRef = useRef(null); const taskFormChecklistDraftRef = useRef(null); const taskFormResourcesRequestRef = useRef(0); + const readingBoardNotificationsRef = useRef>(new Set()); const sensors = useSensors( useSensor(PointerSensor, { @@ -2122,6 +2221,36 @@ function TasksPageContent() { }), ); + const otherBoardsUnreadCount = useMemo( + () => boards.reduce( + (total, item) => ( + item.id === selectedBoardId + ? total + : total + (unreadTaskBoardCounts[item.id] || 0) + ), + 0, + ), + [boards, selectedBoardId, unreadTaskBoardCounts], + ); + + useEffect(() => { + if (!selectedBoardId || (unreadTaskBoardCounts[selectedBoardId] || 0) <= 0) { + return; + } + if (readingBoardNotificationsRef.current.has(selectedBoardId)) { + return; + } + + readingBoardNotificationsRef.current.add(selectedBoardId); + void markTaskBoardAsRead(selectedBoardId) + .catch((readError) => { + setError(getTaskError(readError, "Не удалось отметить уведомления доски прочитанными")); + }) + .finally(() => { + readingBoardNotificationsRef.current.delete(selectedBoardId); + }); + }, [markTaskBoardAsRead, selectedBoardId, unreadTaskBoardCounts]); + useEffect(() => { if (!user?.id) return; setDesktopWideMode( @@ -2619,6 +2748,22 @@ function TasksPageContent() { return () => document.removeEventListener("mousedown", handlePointerDown); }, [cardMenuTaskId]); + useEffect(() => { + if (columnMenuId === null) return undefined; + + function handlePointerDown(event: MouseEvent) { + if ( + columnMenuRef.current && + !columnMenuRef.current.contains(event.target as Node) + ) { + setColumnMenuId(null); + } + } + + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, [columnMenuId]); + const filteredBoardMembers = useMemo(() => { const query = boardMemberSearch.trim().toLowerCase(); if (!query) return employees; @@ -2643,6 +2788,42 @@ function TasksPageContent() { )); }, [boardDepartmentSearch, departments]); + const filteredTaskParticipants = useMemo(() => { + const query = taskParticipantSearch.trim().toLowerCase(); + if (!query) return taskParticipantCandidates; + return taskParticipantCandidates.filter((employee) => ( + [displayUserName(employee), employee.email] + .filter(Boolean) + .join(" ") + .toLowerCase() + .includes(query) + )); + }, [taskParticipantCandidates, taskParticipantSearch]); + + useEffect(() => { + if (!board?.id) { + setTaskParticipantCandidates([]); + return; + } + let cancelled = false; + setTaskParticipantCandidates([]); + void apiClient.getTaskBoardParticipantCandidates(board.id) + .then((candidates) => { + if (!cancelled) setTaskParticipantCandidates(candidates); + }) + .catch((candidatesError) => { + console.error("Не удалось загрузить участников доски:", candidatesError); + }); + return () => { + cancelled = true; + }; + }, [board?.id]); + + const taskFormCreatorId = useMemo(() => { + if (!form.id) return user?.id; + return (board?.tasks || []).find((task) => task.id === form.id)?.created_by?.id; + }, [board?.tasks, form.id, user?.id]); + const searchedTasks = useMemo(() => { const tasks = board?.tasks || []; const query = search.trim().toLowerCase(); @@ -2711,6 +2892,23 @@ function TasksPageContent() { () => activeColumns.find((column) => column.id === viewTask?.column) || null, [activeColumns, viewTask?.column], ); + const displayedTaskParticipants = useMemo( + () => (viewTask?.participants || []).filter( + (participant) => participant.id !== viewTask?.assignee?.id, + ), + [viewTask?.assignee?.id, viewTask?.participants], + ); + const filteredParticipantModalCandidates = useMemo(() => { + const query = participantModalSearch.trim().toLowerCase(); + if (!query) return taskParticipantCandidates; + return taskParticipantCandidates.filter((employee) => ( + [displayUserName(employee), employee.email, employee.position?.name] + .filter(Boolean) + .join(" ") + .toLowerCase() + .includes(query) + )); + }, [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 linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount; const commentsBadgeCount = viewTask?.comments_count ?? taskComments.length; @@ -2810,14 +3008,20 @@ function TasksPageContent() { setForm({ ...emptyForm, column: columnId || firstColumn?.id || "", + participant_ids: user?.id ? [user.id] : [], }); + setTaskParticipantsOpen(false); + setTaskParticipantSearch(""); setLabelName(""); setLabelColor("#38bdf8"); setTaskModalOpen(true); - }, [board?.columns, clearTaskFormResources]); + }, [board?.columns, clearTaskFormResources, user?.id]); const openTaskView = useCallback((task: TaskCard) => { setViewTaskId(task.id); + setParticipantModalOpen(false); + setParticipantModalSearch(""); + setParticipantModalSelection([]); setTaskMenuOpen(false); setViewColumnMenuOpen(false); setLinkedObjectsOpen(false); @@ -2850,6 +3054,10 @@ function TasksPageContent() { setCardMenuTaskId((current) => (current === taskId ? null : taskId)); }, []); + const toggleColumnMenu = useCallback((columnId: number) => { + setColumnMenuId((current) => (current === columnId ? null : columnId)); + }, []); + const closeTaskView = useCallback(() => { if (saving || taskAttachmentsUploading || taskChecklistSaving) return; setViewTaskId(null); @@ -2878,8 +3086,70 @@ function TasksPageContent() { setActivityOpen(false); setCommentDraft(""); setTaskChecklistDraft(""); + setParticipantModalOpen(false); + setParticipantModalSearch(""); + setParticipantModalSelection([]); }, [saving, taskAttachmentsUploading, taskChecklistSaving]); + const openParticipantModal = useCallback(() => { + if (!viewTask) return; + const participantIds = new Set( + (viewTask.participants || []).map((participant) => participant.id), + ); + if (viewTask.created_by?.id) participantIds.add(viewTask.created_by.id); + if (viewTask.assignee?.id) participantIds.add(viewTask.assignee.id); + setParticipantModalSelection([...participantIds]); + setParticipantModalSearch(""); + setParticipantModalOpen(true); + }, [viewTask]); + + const closeParticipantModal = useCallback(() => { + if (saving) return; + setParticipantModalOpen(false); + setParticipantModalSearch(""); + setParticipantModalSelection([]); + }, [saving]); + + const toggleParticipantModalEmployee = useCallback((employeeId: number) => { + if ( + employeeId === viewTask?.created_by?.id || + employeeId === viewTask?.assignee?.id + ) { + return; + } + setParticipantModalSelection((current) => ( + current.includes(employeeId) + ? current.filter((id) => id !== employeeId) + : [...current, employeeId] + )); + }, [viewTask?.assignee?.id, viewTask?.created_by?.id]); + + const addCurrentUserAsParticipant = useCallback(() => { + if (!user?.id) return; + setParticipantModalSelection((current) => ( + current.includes(user.id) ? current : [...current, user.id] + )); + }, [user?.id]); + + const saveParticipantModal = useCallback(async () => { + if (!board || !viewTask || saving) return; + setSaving(true); + setError(null); + try { + await apiClient.updateTask(viewTask.id, { + participant_ids: participantModalSelection, + }); + await loadBoard(board.id); + setParticipantModalOpen(false); + setParticipantModalSearch(""); + setParticipantModalSelection([]); + } catch (participantError) { + setError(getTaskError(participantError, "Не удалось изменить участников задачи")); + } finally { + setSaving(false); + } + }, [board, loadBoard, participantModalSelection, saving, viewTask]); + const openEditTask = useCallback((task: TaskCard) => { taskFormResourcesRequestRef.current += 1; clearTaskFormResources(); @@ -2889,11 +3159,14 @@ function TasksPageContent() { description: task.description || "", column: task.column, assignee_id: task.assignee?.id || "", + participant_ids: (task.participants || []).map((participant) => participant.id), priority: task.priority, due_date: task.due_date || "", label_ids: (task.labels || []).map((label) => label.id), }); setLabelName(""); + setTaskParticipantsOpen(false); + setTaskParticipantSearch(""); setLabelColor("#38bdf8"); setViewTaskId(null); setTaskMenuOpen(false); @@ -2909,6 +3182,8 @@ function TasksPageContent() { setTaskModalOpen(false); setForm(emptyForm); setLabelName(""); + setTaskParticipantsOpen(false); + setTaskParticipantSearch(""); clearTaskFormResources(); }, [clearTaskFormResources, saving]); @@ -2949,10 +3224,22 @@ function TasksPageContent() { }, [saving]); const openCreateColumn = useCallback(() => { + setColumnMenuId(null); setColumnForm(emptyColumnForm); setColumnModalOpen(true); }, []); + const openEditColumn = useCallback((column: TaskColumn) => { + setColumnMenuId(null); + setColumnForm({ + id: column.id, + name: column.name, + color: column.color || "#38bdf8", + is_done: column.is_done, + }); + setColumnModalOpen(true); + }, []); + const closeColumnModal = useCallback(() => { if (saving) return; setColumnModalOpen(false); @@ -3101,26 +3388,57 @@ function TasksPageContent() { setSaving(true); setError(null); try { - const maxPosition = Math.max( - 0, - ...(board.columns || []).map((column) => column.position || 0), - ); - await apiClient.createTaskColumn({ - board: board.id, + const payload = { name: columnForm.name.trim(), color: columnForm.color, is_done: columnForm.is_done, - position: maxPosition + 1000, - }); + }; + if (columnForm.id) { + await apiClient.updateTaskColumn(columnForm.id, payload); + } else { + const maxPosition = Math.max( + 0, + ...(board.columns || []).map((column) => column.position || 0), + ); + await apiClient.createTaskColumn({ + ...payload, + board: board.id, + position: maxPosition + 1000, + }); + } await loadBoard(board.id); setColumnModalOpen(false); setColumnForm(emptyColumnForm); } catch (saveError) { - setError(getTaskError(saveError, "Не удалось создать колонку")); + setError(getTaskError( + saveError, + columnForm.id ? "Не удалось изменить колонку" : "Не удалось создать колонку", + )); } finally { setSaving(false); } - }, [board, columnForm.color, columnForm.is_done, columnForm.name, loadBoard]); + }, [board, columnForm.color, columnForm.id, columnForm.is_done, columnForm.name, loadBoard]); + + const deleteColumn = useCallback(async (column: TaskColumn) => { + if (!board || saving) return; + if (!window.confirm(`Удалить колонку "${column.name}"?`)) return; + + setColumnMenuId(null); + setSaving(true); + setError(null); + try { + await apiClient.deleteTaskColumn(column.id); + await loadBoard(board.id); + setSelectedColumnTarget((current) => ( + current === column.id ? "all" : current + )); + } catch (deleteError) { + setError(getTaskError(deleteError, "Не удалось удалить колонку")); + await loadBoard(board.id); + } finally { + setSaving(false); + } + }, [board, loadBoard, saving]); const persistTaskFormResources = useCallback(async (taskId: number) => { for (const itemId of [...taskFormRemovedChecklistItemIds]) { @@ -3234,6 +3552,7 @@ function TasksPageContent() { title: form.title.trim(), description: form.description.trim(), assignee_id: form.assignee_id || null, + participant_ids: form.participant_ids, priority: form.priority, due_date: form.due_date || null, label_ids: form.label_ids, @@ -3314,6 +3633,21 @@ function TasksPageContent() { })); }, []); + const toggleTaskParticipant = useCallback((employeeId: number) => { + setForm((current) => { + const requiredIds = new Set(); + if (taskFormCreatorId) requiredIds.add(taskFormCreatorId); + if (current.assignee_id) requiredIds.add(Number(current.assignee_id)); + if (requiredIds.has(employeeId)) return current; + return { + ...current, + participant_ids: current.participant_ids.includes(employeeId) + ? current.participant_ids.filter((id) => id !== employeeId) + : [...current.participant_ids, employeeId], + }; + }); + }, [taskFormCreatorId]); + const deleteTask = useCallback(async (task: TaskCard) => { if (!board || saving) return; if (!window.confirm(`Удалить задачу "${task.title}"?`)) return; @@ -4042,6 +4376,11 @@ function TasksPageContent() { ]); const handleDragStart = useCallback((event: DragStartEvent) => { + if (event.active.data.current?.type !== "task") { + setActiveTaskId(null); + setColumnMenuId(null); + return; + } const taskId = Number(String(event.active.id).replace("task-", "")); setActiveTaskId(Number.isFinite(taskId) ? taskId : null); }, []); @@ -4053,6 +4392,37 @@ function TasksPageContent() { const handleDragEnd = useCallback(async (event: DragEndEvent) => { setActiveTaskId(null); if (!board || !event.over) return; + + if (event.active.data.current?.type === "column") { + const activeColumnId = Number(String(event.active.id).replace("column-", "")); + const overColumnId = Number(String(event.over.id).replace("column-", "")); + const oldIndex = activeColumns.findIndex((column) => column.id === activeColumnId); + const newIndex = activeColumns.findIndex((column) => column.id === overColumnId); + if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return; + + const reorderedColumns = arrayMove(activeColumns, oldIndex, newIndex); + const orderedIds = reorderedColumns.map((column) => column.id); + setBoard((current) => current ? { + ...current, + columns: [ + ...reorderedColumns.map((column, index) => ({ + ...column, + position: (index + 1) * 1000, + })), + ...current.columns.filter((column) => column.is_archived), + ], + } : current); + + try { + await apiClient.reorderTaskColumns(board.id, orderedIds); + await loadBoard(board.id); + } catch (reorderError) { + setError(getTaskError(reorderError, "Не удалось изменить порядок колонок")); + await loadBoard(board.id); + } + return; + } + const taskId = Number(String(event.active.id).replace("task-", "")); const columnId = Number(String(event.over.id).replace("column-", "")); if (!taskId || !columnId) return; @@ -4066,7 +4436,7 @@ function TasksPageContent() { setError(getTaskError(moveError, "Не удалось переместить задачу")); await loadBoard(board.id); } - }, [board, loadBoard]); + }, [activeColumns, board, loadBoard]); if (loading) { return ( @@ -4209,6 +4579,14 @@ function TasksPageContent() { {board?.name || "Доска"} + {otherBoardsUnreadCount > 0 ? ( + + {otherBoardsUnreadCount > 99 ? "99+" : otherBoardsUnreadCount} + + ) : null}
)} @@ -4755,12 +5149,6 @@ function TasksPageContent() { {viewTask.priority_display || priorityMeta[viewTask.priority]?.label || viewTask.priority} - {viewTask.assignee ? ( - - - {displayUserName(viewTask.assignee)} - - ) : null} {viewTask.due_date ? ( @@ -4768,6 +5156,63 @@ function TasksPageContent() { ) : null}
+
+ + + Исполнитель + + {viewTask.assignee ? ( + + + {displayUserName(viewTask.assignee)} + + ) : ( + Не назначен + )} +
+
+ + + Участники + + {displayedTaskParticipants.slice(0, 5).map((participant) => ( + + + {displayUserName(participant)} + + ))} + {displayedTaskParticipants.length > 5 ? ( + + +{displayedTaskParticipants.length - 5} + + ) : null} + +
@@ -5426,12 +5871,6 @@ function TasksPageContent() { {viewTaskColumn?.name || viewTask.column_name || "Не указана"}

-
-

Исполнитель

-

- {viewTask.assignee ? displayUserName(viewTask.assignee) : "Не назначен"} -

-
{viewTask.completed_at ? (

Завершено

@@ -5445,6 +5884,134 @@ function TasksPageContent() { ) : null} + + + +
+ )} + > +
+ {viewTask ? ( +
+

Задача #{viewTask.id}

+

+ {viewTask.title} +

+
+ ) : null} + +
+
+

Сотрудники

+

+ Выбрано: {participantModalSelection.length} +

+
+ +
+ + + +
+ {filteredParticipantModalCandidates.length > 0 ? ( + filteredParticipantModalCandidates.map((employee) => { + const selected = participantModalSelection.includes(employee.id); + const isCreator = employee.id === viewTask?.created_by?.id; + const isAssignee = employee.id === viewTask?.assignee?.id; + const required = isCreator || isAssignee; + return ( + + ); + }) + ) : ( +

+ Сотрудники не найдены +

+ )} +
+
+ + Исполнитель setTaskParticipantSearch(event.target.value)} + className="app-input w-full rounded-xl py-2 pl-8 pr-3 text-sm" + placeholder="Поиск сотрудника" + /> + +
+ {filteredTaskParticipants.length > 0 ? filteredTaskParticipants.map((employee) => { + const selected = form.participant_ids.includes(employee.id); + const required = employee.id === taskFormCreatorId || employee.id === Number(form.assignee_id); + return ( + + ); + }) : ( +
+

Сотрудники не найдены

+
+ )} +
+ + ) : null} + +
diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 61248eb4..b969fad8 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -61,7 +61,7 @@ const navItems = [ { href: "/guests", label: "Гости", icon: UserRoundPlus, category: NAV_NOTIFICATION_CATEGORIES.guests }, // { href: "/equipment", label: "Оборудование", icon: Monitor }, { href: "/procurement", label: "Закупки", icon: ShoppingCart, category: NAV_NOTIFICATION_CATEGORIES.procurement, autoReadOnNavigate: false }, - { href: "/tasks", label: "Доска", icon: Kanban, category: NAV_NOTIFICATION_CATEGORIES.tasks }, + { href: "/tasks", label: "Доска", icon: Kanban, category: NAV_NOTIFICATION_CATEGORIES.tasks, autoReadOnNavigate: false }, { href: "/documents?section=folders", label: "Документы", icon: FileText, category: NAV_NOTIFICATION_CATEGORIES.documents, documentSection: "folders" as const }, { href: "/documents?section=regulations", label: "Регламенты", icon: ScrollText, category: NAV_NOTIFICATION_CATEGORIES.regulations, autoReadOnNavigate: false, documentSection: "regulations" as const }, { href: "/finances", label: "Финансы", icon: Wallet }, diff --git a/frontend/src/contexts/NotificationsContext.tsx b/frontend/src/contexts/NotificationsContext.tsx index 7002abf4..70ba6740 100644 --- a/frontend/src/contexts/NotificationsContext.tsx +++ b/frontend/src/contexts/NotificationsContext.tsx @@ -40,6 +40,7 @@ interface NotificationsContextType { unreadCount: number; unreadCategoryCounts: Record; unreadProcurementRequestCounts: Record; + unreadTaskBoardCounts: Record; unreadRegulationDepartmentCounts: Record; unreadCompanyRegulationCount: number; unreadPersonalRegulationCount: number; @@ -49,6 +50,7 @@ interface NotificationsContextType { markAsRead: (id: number) => Promise; markAllAsRead: () => Promise; markCategoryAsRead: (category: string) => Promise; + markTaskBoardAsRead: (boardId: number) => Promise; deleteNotification: (id: number) => Promise; deleteAllRead: () => Promise; } @@ -64,6 +66,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode const [unreadCount, setUnreadCount] = useState(0); const [unreadCategoryCounts, setUnreadCategoryCounts] = useState>({}); const [unreadProcurementRequestCounts, setUnreadProcurementRequestCounts] = useState>({}); + const [unreadTaskBoardCounts, setUnreadTaskBoardCounts] = useState>({}); const [unreadRegulationDepartmentCounts, setUnreadRegulationDepartmentCounts] = useState>({}); const [unreadCompanyRegulationCount, setUnreadCompanyRegulationCount] = useState(0); const [unreadPersonalRegulationCount, setUnreadPersonalRegulationCount] = useState(0); @@ -78,6 +81,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode total?: number; verbs?: Array<{ verb?: string; unread?: number }>; procurement_requests?: Array<{ request_id?: number; unread?: number }>; + task_boards?: Array<{ board_id?: number; unread?: number }>; regulation_departments?: Array<{ department_id?: number; unread?: number }>; regulation_company_unread?: number; regulation_personal_unread?: number; @@ -88,6 +92,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode const procurementRequests = Array.isArray(summary.procurement_requests) ? summary.procurement_requests : []; + const taskBoards = Array.isArray(summary.task_boards) + ? summary.task_boards + : []; const regulationDepartments = Array.isArray(summary.regulation_departments) ? summary.regulation_departments : []; @@ -108,6 +115,15 @@ export function NotificationsProvider({ children }: { children: React.ReactNode nextProcurementRequestCounts[requestId] = item.unread; }); + const nextTaskBoardCounts: Record = {}; + taskBoards.forEach((item) => { + const boardId = Number(item.board_id); + if (!Number.isFinite(boardId) || boardId <= 0 || typeof item.unread !== 'number' || item.unread <= 0) { + return; + } + nextTaskBoardCounts[boardId] = item.unread; + }); + const nextRegulationDepartmentCounts: Record = {}; regulationDepartments.forEach((item) => { const departmentId = Number(item.department_id); @@ -119,6 +135,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode setUnreadCategoryCounts(nextCategoryCounts); setUnreadProcurementRequestCounts(nextProcurementRequestCounts); + setUnreadTaskBoardCounts(nextTaskBoardCounts); setUnreadRegulationDepartmentCounts(nextRegulationDepartmentCounts); setUnreadCompanyRegulationCount( typeof summary.regulation_company_unread === 'number' @@ -203,6 +220,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode setUnreadCount(0); setUnreadCategoryCounts({}); setUnreadProcurementRequestCounts({}); + setUnreadTaskBoardCounts({}); setUnreadRegulationDepartmentCounts({}); setUnreadCompanyRegulationCount(0); setUnreadPersonalRegulationCount(0); @@ -259,6 +277,15 @@ export function NotificationsProvider({ children }: { children: React.ReactNode })); } } + if (category === 'Доска') { + const boardId = Number(incomingNotification.data?.board_id); + if (Number.isFinite(boardId) && boardId > 0) { + setUnreadTaskBoardCounts(prev => ({ + ...prev, + [boardId]: (prev[boardId] || 0) + 1, + })); + } + } } } void syncUnreadSummary(); @@ -381,6 +408,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode setUnreadCount(0); setUnreadCategoryCounts({}); setUnreadProcurementRequestCounts({}); + setUnreadTaskBoardCounts({}); setUnreadRegulationDepartmentCounts({}); setUnreadCompanyRegulationCount(0); setUnreadPersonalRegulationCount(0); @@ -407,6 +435,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode if (category === 'Закупки') { setUnreadProcurementRequestCounts({}); } + if (category === 'Доска') { + setUnreadTaskBoardCounts({}); + } if (category === 'Регламенты') { setUnreadRegulationDepartmentCounts({}); setUnreadCompanyRegulationCount(0); @@ -415,6 +446,25 @@ export function NotificationsProvider({ children }: { children: React.ReactNode void syncUnreadSummary(); }, [syncUnreadSummary]); + const markTaskBoardAsRead = useCallback(async (boardId: number) => { + const result = await apiClient.markTaskBoardNotificationsRead(boardId); + const notificationIds = new Set(result.notification_ids || []); + if (notificationIds.size > 0) { + setNotifications(prev => prev.map(notification => ( + notificationIds.has(notification.id) + ? { ...notification, is_read: true, unread: false } + : notification + ))); + } + setUnreadTaskBoardCounts(prev => { + if (!(boardId in prev)) return prev; + const next = { ...prev }; + delete next[boardId]; + return next; + }); + void syncUnreadSummary(); + }, [syncUnreadSummary]); + const deleteNotification = useCallback(async (id: number) => { await apiClient.deleteNotification(id); setNotifications(prev => prev.filter(n => n.id !== id)); @@ -432,6 +482,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode unreadCount, unreadCategoryCounts, unreadProcurementRequestCounts, + unreadTaskBoardCounts, unreadRegulationDepartmentCounts, unreadCompanyRegulationCount, unreadPersonalRegulationCount, @@ -441,6 +492,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode markAsRead, markAllAsRead, markCategoryAsRead, + markTaskBoardAsRead, deleteNotification, deleteAllRead, }; diff --git a/frontend/src/lib/api/notifications.ts b/frontend/src/lib/api/notifications.ts index 36e32000..87272357 100644 --- a/frontend/src/lib/api/notifications.ts +++ b/frontend/src/lib/api/notifications.ts @@ -11,6 +11,10 @@ export type UnreadNotificationsSummary = { request_id: number; unread: number; }>; + task_boards?: Array<{ + board_id: number; + unread: number; + }>; regulation_departments?: Array<{ department_id: number; unread: number; diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts index 73c10d9b..9e31c2be 100644 --- a/frontend/src/lib/api/tasks.ts +++ b/frontend/src/lib/api/tasks.ts @@ -19,6 +19,7 @@ import type { TaskLinkedProcurementRequest, TaskLinkedRequest, TaskExternalLink, + User, } from "@/types/api"; import { buildQuery, type RawRequestFn, type RequestFn } from "./utils"; @@ -39,6 +40,8 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) { request("/api/v1/tasks/boards/default/"), getTaskBoard: (id: number): Promise => request(`/api/v1/tasks/boards/${id}/`), + getTaskBoardParticipantCandidates: (id: number): Promise => + request(`/api/v1/tasks/boards/${id}/participant-candidates/`), createTaskBoard: (data: Record): Promise => request("/api/v1/tasks/boards/", { method: "POST", @@ -53,11 +56,35 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) { request(`/api/v1/tasks/boards/${id}/`, { method: "DELETE" }), setDefaultTaskBoard: (id: number): Promise => request(`/api/v1/tasks/boards/${id}/set-default/`, { method: "POST" }), + markTaskBoardNotificationsRead: ( + id: number, + ): Promise<{ status: string; count: number; notification_ids: number[] }> => + request(`/api/v1/tasks/boards/${id}/notifications/read/`, { + method: "POST", + }), createTaskColumn: (data: Record): Promise => request("/api/v1/tasks/columns/", { method: "POST", body: JSON.stringify(data), }), + updateTaskColumn: ( + id: number, + data: Record, + ): Promise => + request(`/api/v1/tasks/columns/${id}/`, { + method: "PATCH", + body: JSON.stringify(data), + }), + deleteTaskColumn: (id: number): Promise => + request(`/api/v1/tasks/columns/${id}/`, { method: "DELETE" }), + reorderTaskColumns: ( + board: number, + columnIds: number[], + ): Promise<{ board: number; column_ids: number[] }> => + request("/api/v1/tasks/columns/reorder/", { + method: "POST", + body: JSON.stringify({ board, column_ids: columnIds }), + }), createTaskLabel: (data: Record): Promise => request("/api/v1/tasks/labels/", { method: "POST", diff --git a/frontend/src/lib/verbTranslations.ts b/frontend/src/lib/verbTranslations.ts index 8fd69c92..834442c1 100644 --- a/frontend/src/lib/verbTranslations.ts +++ b/frontend/src/lib/verbTranslations.ts @@ -96,6 +96,7 @@ export const VERB_CATEGORIES: Record = { feed_post_reaction: 'Новости', // Доска + task_created: 'Доска', task_assigned: 'Доска', task_reassigned: 'Доска', task_comment: 'Доска', @@ -106,6 +107,14 @@ export const VERB_CATEGORIES: Record = { task_reopened: 'Доска', task_linked_object_added: 'Доска', task_board_member_added: 'Доска', + task_updated: 'Доска', + task_moved: 'Доска', + task_deleted: 'Доска', + task_attachment_added: 'Доска', + task_attachment_removed: 'Доска', + task_checklist_changed: 'Доска', + task_linked_object_removed: 'Доска', + task_comment_changed: 'Доска', // Отделы department_new_employee: 'Отдел', @@ -222,6 +231,7 @@ export const VERB_NAMES: Record = { feed_post_reaction: 'Реакция на публикацию', // Доска + task_created: 'Новая задача', task_assigned: 'Вам назначена задача', task_reassigned: 'Исполнитель задачи изменён', task_comment: 'Комментарий к задаче', @@ -232,6 +242,14 @@ export const VERB_NAMES: Record = { task_reopened: 'Задача возвращена в работу', task_linked_object_added: 'К задаче добавлен связанный объект', task_board_member_added: 'Вас добавили на доску', + task_updated: 'Задача изменена', + task_moved: 'Задача перемещена', + task_deleted: 'Задача удалена', + task_attachment_added: 'К задаче добавлен файл', + task_attachment_removed: 'Из задачи удалён файл', + task_checklist_changed: 'Чек-лист задачи изменён', + task_linked_object_removed: 'Связь с задачей удалена', + task_comment_changed: 'Комментарий к задаче изменён', // Отделы department_new_employee: 'Новый сотрудник в отделе', diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 73271768..5bd78d83 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -204,6 +204,7 @@ export interface TaskCard { description?: string; created_by?: User; assignee?: User | null; + participants?: User[]; labels?: TaskLabel[]; priority: TaskPriority; priority_display?: string;