diff --git a/backend/api/v1/tasks/__init__.py b/backend/api/v1/tasks/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/api/v1/tasks/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py new file mode 100644 index 00000000..43a2bdf8 --- /dev/null +++ b/backend/api/v1/tasks/serializers.py @@ -0,0 +1,423 @@ +from django.db.models import Max +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from api.v1.employees.serializers import EmployeeBriefSerializer +from api.v1.employees.serializers.department import DepartmentBriefSerializer +from communications.models import Message +from communications.serialization import serialize_message +from communications.utils import user_can_access_chat +from employees.models import Department +from schedule.models import Event +from api.v1.schedule.serializers import EventSerializer +from tasks.access import ( + user_can_access_calendar_event, + user_can_access_task_board, +) +from tasks.models import ( + Task, + TaskActivity, + TaskBoard, + TaskColumn, + TaskLabel, + TaskLinkedObject, + TaskLinkedObjectKind, +) + +User = get_user_model() + + +DEFAULT_COLUMNS = [ + ("Новые", "#38bdf8", False), + ("В работе", "#f59e0b", False), + ("На проверке", "#8b5cf6", False), + ("Готово", "#22c55e", True), +] + + +def create_default_columns(board): + if board.columns.exists(): + return + TaskColumn.objects.bulk_create( + [ + TaskColumn( + board=board, + name=name, + color=color, + is_done=is_done, + position=index * 1000, + ) + for index, (name, color, is_done) in enumerate(DEFAULT_COLUMNS) + ] + ) + + +def validate_board_access(serializer, board): + request = serializer.context.get("request") + user = getattr(request, "user", None) + if not user_can_access_task_board(user, board): + raise serializers.ValidationError("У вас нет доступа к этой доске.") + return board + + +class TaskLabelSerializer(serializers.ModelSerializer): + class Meta: + model = TaskLabel + fields = ["id", "board", "name", "color"] + read_only_fields = ["id"] + + def validate_board(self, board): + return validate_board_access(self, board) + + +class TaskColumnSerializer(serializers.ModelSerializer): + tasks_count = serializers.IntegerField(read_only=True, default=0) + + class Meta: + model = TaskColumn + fields = [ + "id", + "board", + "name", + "position", + "color", + "is_done", + "is_archived", + "tasks_count", + "created_at", + "updated_at", + ] + read_only_fields = [ + "id", + "created_by", + "member_details", + "department_details", + "columns", + "labels", + "tasks", + "tasks_count", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at", "tasks_count"] + + def validate_board(self, board): + return validate_board_access(self, board) + + +class TaskSerializer(serializers.ModelSerializer): + created_by = EmployeeBriefSerializer(read_only=True) + assignee = EmployeeBriefSerializer(read_only=True) + assignee_id = serializers.PrimaryKeyRelatedField( + source="assignee", + queryset=User.objects.all(), + required=False, + allow_null=True, + write_only=True, + ) + labels = TaskLabelSerializer(many=True, read_only=True) + label_ids = serializers.PrimaryKeyRelatedField( + source="labels", + queryset=TaskLabel.objects.all(), + many=True, + required=False, + write_only=True, + ) + priority_display = serializers.CharField( + source="get_priority_display", + read_only=True, + ) + column_name = serializers.CharField(source="column.name", read_only=True) + linked_messages_count = serializers.SerializerMethodField() + linked_events_count = serializers.SerializerMethodField() + linked_objects_count = serializers.SerializerMethodField() + + class Meta: + model = Task + fields = [ + "id", + "board", + "column", + "column_name", + "title", + "description", + "created_by", + "assignee", + "assignee_id", + "labels", + "label_ids", + "priority", + "priority_display", + "due_date", + "position", + "completed_at", + "linked_messages_count", + "linked_events_count", + "linked_objects_count", + "created_at", + "updated_at", + ] + read_only_fields = [ + "id", + "created_by", + "column_name", + "priority_display", + "completed_at", + "linked_messages_count", + "linked_events_count", + "linked_objects_count", + "created_at", + "updated_at", + ] + + @extend_schema_field(serializers.IntegerField()) + def get_linked_messages_count(self, obj): + if hasattr(obj, "linked_messages_count"): + return obj.linked_messages_count + return obj.linked_objects.filter( + kind=TaskLinkedObjectKind.MESSAGE, + ).count() + + @extend_schema_field(serializers.IntegerField()) + def get_linked_events_count(self, obj): + if hasattr(obj, "linked_events_count"): + return obj.linked_events_count + return obj.linked_objects.filter( + kind=TaskLinkedObjectKind.CALENDAR_EVENT, + ).count() + + @extend_schema_field(serializers.IntegerField()) + def get_linked_objects_count(self, obj): + if hasattr(obj, "linked_objects_count"): + return obj.linked_objects_count + return obj.linked_objects.count() + + def validate(self, attrs): + request = self.context.get("request") + user = getattr(request, "user", None) + board = attrs.get("board") or getattr(self.instance, "board", None) + column = attrs.get("column") or getattr(self.instance, "column", None) + labels = attrs.get("labels") + + if board and not user_can_access_task_board(user, board): + raise serializers.ValidationError( + {"board": "У вас нет доступа к этой доске."} + ) + + if column and board and column.board_id != board.id: + raise serializers.ValidationError( + {"column": "Колонка должна принадлежать выбранной доске."} + ) + + if labels is not None and board: + invalid_labels = [ + label.id for label in labels if label.board_id != board.id + ] + if invalid_labels: + raise serializers.ValidationError( + {"label_ids": "Метки должны принадлежать выбранной доске."} + ) + + return attrs + + def create(self, validated_data): + labels = validated_data.pop("labels", []) + if "position" not in validated_data: + column = validated_data["column"] + max_position = ( + Task.objects.filter(column=column).aggregate(Max("position"))[ + "position__max" + ] + or 0 + ) + validated_data["position"] = max_position + 1000 + task = Task.objects.create(**validated_data) + if labels: + task.labels.set(labels) + return task + + def update(self, instance, validated_data): + labels = validated_data.pop("labels", None) + task = super().update(instance, validated_data) + if labels is not None: + task.labels.set(labels) + return task + + +class TaskBoardSerializer(serializers.ModelSerializer): + created_by = EmployeeBriefSerializer(read_only=True) + members = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), + many=True, + required=False, + ) + member_details = EmployeeBriefSerializer( + source="members", + many=True, + read_only=True, + ) + departments = serializers.PrimaryKeyRelatedField( + queryset=Department.objects.all(), + many=True, + required=False, + ) + department_details = DepartmentBriefSerializer( + source="departments", + many=True, + read_only=True, + ) + columns = TaskColumnSerializer(many=True, read_only=True) + labels = TaskLabelSerializer(many=True, read_only=True) + tasks = TaskSerializer(many=True, read_only=True) + tasks_count = serializers.IntegerField(read_only=True, default=0) + + class Meta: + model = TaskBoard + fields = [ + "id", + "name", + "description", + "created_by", + "members", + "member_details", + "departments", + "department_details", + "is_archived", + "columns", + "labels", + "tasks", + "tasks_count", + "created_at", + "updated_at", + ] + + +class TaskLinkedMessageSerializer(serializers.ModelSerializer): + created_by = EmployeeBriefSerializer(read_only=True) + message = serializers.SerializerMethodField() + message_id = serializers.IntegerField(source="object_id", read_only=True) + can_open = serializers.SerializerMethodField() + object_url = serializers.SerializerMethodField() + + class Meta: + model = TaskLinkedObject + fields = [ + "id", + "kind", + "message_id", + "message", + "can_open", + "object_url", + "created_by", + "created_at", + ] + read_only_fields = fields + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_message(self, obj): + if obj.kind != TaskLinkedObjectKind.MESSAGE: + return None + + message = getattr(obj, "content_object", None) + if message is None: + return None + + return serialize_message(message, include_linked_tasks=False) + + @extend_schema_field(serializers.BooleanField()) + def get_can_open(self, obj): + message = getattr(obj, "content_object", None) + request = self.context.get("request") + user = getattr(request, "user", None) + if obj.kind != TaskLinkedObjectKind.MESSAGE or message is None or not user: + return False + return user_can_access_chat(message.chat, user) + + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_object_url(self, obj): + message = getattr(obj, "content_object", None) + if message is None or not self.get_can_open(obj): + return None + return f"/messages/{message.chat_id}?message={message.id}" + + +def get_message_content_type(): + return ContentType.objects.get_for_model(Message) + + +class TaskActivitySerializer(serializers.ModelSerializer): + actor = EmployeeBriefSerializer(read_only=True) + action_display = serializers.CharField(source="get_action_display", read_only=True) + + class Meta: + model = TaskActivity + fields = [ + "id", + "action", + "action_display", + "actor", + "object_kind", + "object_id", + "metadata", + "created_at", + ] + read_only_fields = fields + + +class TaskLinkedCalendarEventSerializer(serializers.ModelSerializer): + created_by = EmployeeBriefSerializer(read_only=True) + event = serializers.SerializerMethodField() + event_id = serializers.IntegerField(source="object_id", read_only=True) + can_open = serializers.SerializerMethodField() + object_url = serializers.SerializerMethodField() + + class Meta: + model = TaskLinkedObject + fields = [ + "id", + "kind", + "event_id", + "event", + "can_open", + "object_url", + "created_by", + "created_at", + ] + read_only_fields = fields + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_event(self, obj): + if obj.kind != TaskLinkedObjectKind.CALENDAR_EVENT: + return None + + event = getattr(obj, "content_object", None) + if event is None: + return None + + return EventSerializer(event, context=self.context).data + + @extend_schema_field(serializers.BooleanField()) + def get_can_open(self, obj): + event = getattr(obj, "content_object", None) + request = self.context.get("request") + user = getattr(request, "user", None) + if ( + obj.kind != TaskLinkedObjectKind.CALENDAR_EVENT + or event is None + or not user + ): + return False + return user_can_access_calendar_event(user, event) + + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_object_url(self, obj): + event = getattr(obj, "content_object", None) + if event is None or not self.get_can_open(obj): + return None + return f"/calendar?calendar={event.calendar_id}&event={event.id}" + + +def get_calendar_event_content_type(): + return ContentType.objects.get_for_model(Event) diff --git a/backend/api/v1/tasks/urls.py b/backend/api/v1/tasks/urls.py new file mode 100644 index 00000000..5516f37b --- /dev/null +++ b/backend/api/v1/tasks/urls.py @@ -0,0 +1,21 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ( + TaskBoardViewSet, + TaskColumnViewSet, + TaskLabelViewSet, + TaskViewSet, +) + +app_name = "tasks" + +router = DefaultRouter() +router.register(r"boards", TaskBoardViewSet, basename="task-board") +router.register(r"columns", TaskColumnViewSet, basename="task-column") +router.register(r"labels", TaskLabelViewSet, basename="task-label") +router.register(r"", TaskViewSet, basename="task") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py new file mode 100644 index 00000000..1f94b056 --- /dev/null +++ b/backend/api/v1/tasks/views.py @@ -0,0 +1,632 @@ +from django.db.models import Count, Max, Q +from communications.models import Message +from communications.utils import user_can_access_chat +from rest_framework import filters, status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from schedule.models import Event + +from tasks.access import task_board_access_q, user_can_access_calendar_event +from tasks.models import ( + Task, + TaskActivity, + TaskActivityAction, + TaskBoard, + TaskColumn, + TaskLabel, + TaskLinkedObject, + TaskLinkedObjectKind, +) +from tasks.realtime import ( + get_task_board_recipient_ids, + send_task_board_update, +) + +from .serializers import ( + TaskActivitySerializer, + TaskBoardSerializer, + TaskColumnSerializer, + TaskLabelSerializer, + TaskLinkedCalendarEventSerializer, + TaskLinkedMessageSerializer, + TaskSerializer, + create_default_columns, + get_calendar_event_content_type, + get_message_content_type, +) + + +def create_task_activity( + task, + actor, + action, + *, + object_kind="", + object_id=None, + metadata=None, +): + TaskActivity.objects.create( + task=task, + actor=actor if getattr(actor, "is_authenticated", False) else None, + action=action, + object_kind=object_kind or "", + object_id=object_id, + metadata=metadata or {}, + ) + + +def normalize_column_positions(column): + task_ids = list( + column.tasks.order_by("position", "-created_at", "id").values_list( + "id", + flat=True, + ) + ) + for index, task_id in enumerate(task_ids): + Task.objects.filter(id=task_id).update(position=(index + 1) * 1000) + + +class TaskBoardViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = TaskBoardSerializer + + def get_queryset(self): + return ( + TaskBoard.objects.filter(is_archived=False) + .filter(task_board_access_q(self.request.user)) + .select_related("created_by") + .prefetch_related( + "members", + "departments", + "columns", + "labels", + "tasks__created_by", + "tasks__assignee", + "tasks__labels", + "tasks__linked_objects", + ) + .annotate(tasks_count=Count("tasks", distinct=True)) + .distinct() + .order_by("name", "id") + ) + + def perform_create(self, serializer): + board = serializer.save(created_by=self.request.user) + create_default_columns(board) + send_task_board_update(board, "created", "board", board.id) + + def perform_update(self, serializer): + board = self.get_object() + previous_recipient_ids = get_task_board_recipient_ids(board) + board = serializer.save() + recipient_ids = previous_recipient_ids | get_task_board_recipient_ids(board) + send_task_board_update( + board, + "updated", + "board", + board.id, + recipient_ids=recipient_ids, + ) + + def perform_destroy(self, instance): + recipient_ids = get_task_board_recipient_ids(instance) + board_id = instance.id + instance.delete() + send_task_board_update( + None, + "deleted", + "board", + board_id, + recipient_ids=recipient_ids, + board_id=board_id, + ) + + @action(detail=False, methods=["get"], url_path="default") + def default(self, request): + board = self.get_queryset().order_by("id").first() + if not board: + board = TaskBoard.objects.create( + name="Рабочая доска", + description="", + created_by=request.user, + ) + create_default_columns(board) + serializer = self.get_serializer(self.get_queryset().get(id=board.id)) + return Response(serializer.data) + + +class TaskColumnViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = TaskColumnSerializer + + def get_queryset(self): + queryset = ( + TaskColumn.objects.filter(board__is_archived=False) + .filter( + board__in=TaskBoard.objects.filter( + task_board_access_q(self.request.user) + ) + ) + .select_related("board") + .annotate(tasks_count=Count("tasks", distinct=True)) + .order_by("board_id", "position", "id") + ) + board = self.request.query_params.get("board") + if board: + queryset = queryset.filter(board_id=board) + return queryset + + def perform_create(self, serializer): + column = serializer.save() + send_task_board_update(column.board, "created", "column", column.id) + + def perform_update(self, serializer): + column = serializer.save() + send_task_board_update(column.board, "updated", "column", column.id) + + def perform_destroy(self, instance): + board = instance.board + column_id = instance.id + instance.delete() + send_task_board_update(board, "deleted", "column", column_id) + + +class TaskLabelViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = TaskLabelSerializer + + def get_queryset(self): + queryset = ( + TaskLabel.objects.filter(board__is_archived=False) + .filter( + board__in=TaskBoard.objects.filter( + task_board_access_q(self.request.user) + ) + ) + .select_related("board") + ) + board = self.request.query_params.get("board") + if board: + queryset = queryset.filter(board_id=board) + return queryset.order_by("name", "id") + + def perform_create(self, serializer): + label = serializer.save() + send_task_board_update(label.board, "created", "label", label.id) + + def perform_update(self, serializer): + label = serializer.save() + send_task_board_update(label.board, "updated", "label", label.id) + + def perform_destroy(self, instance): + board = instance.board + label_id = instance.id + instance.delete() + send_task_board_update(board, "deleted", "label", label_id) + + +class TaskViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = TaskSerializer + filter_backends = [filters.SearchFilter, filters.OrderingFilter] + search_fields = ["title", "description"] + ordering_fields = ["position", "created_at", "updated_at", "due_date"] + ordering = ["position", "-created_at"] + + def get_queryset(self): + queryset = ( + Task.objects.filter(board__is_archived=False) + .filter( + board__in=TaskBoard.objects.filter( + task_board_access_q(self.request.user) + ) + ) + .select_related("board", "column", "created_by", "assignee") + .prefetch_related("labels") + .annotate( + linked_messages_count=Count( + "linked_objects", + filter=Q(linked_objects__kind=TaskLinkedObjectKind.MESSAGE), + distinct=True, + ), + linked_events_count=Count( + "linked_objects", + filter=Q( + linked_objects__kind=TaskLinkedObjectKind.CALENDAR_EVENT + ), + distinct=True, + ), + linked_objects_count=Count("linked_objects", distinct=True), + ) + ) + params = self.request.query_params + if board := params.get("board"): + queryset = queryset.filter(board_id=board) + if column := params.get("column"): + queryset = queryset.filter(column_id=column) + if assignee := params.get("assignee"): + queryset = queryset.filter(assignee_id=assignee) + if priority := params.get("priority"): + queryset = queryset.filter(priority=priority) + if params.get("unassigned") == "true": + queryset = queryset.filter(assignee__isnull=True) + return queryset + + def perform_create(self, serializer): + task = serializer.save(created_by=self.request.user) + create_task_activity( + task, + self.request.user, + TaskActivityAction.CREATED, + metadata={"column": task.column.name}, + ) + send_task_board_update(task.board, "created", "task", task.id) + + def perform_update(self, serializer): + instance = serializer.instance + previous = { + "title": instance.title, + "description": instance.description, + "assignee_id": instance.assignee_id, + "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)), + } + task = serializer.save() + current = { + "title": task.title, + "description": task.description, + "assignee_id": task.assignee_id, + "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)), + } + changed_fields = [ + field for field, value in current.items() if previous[field] != value + ] + if changed_fields: + create_task_activity( + task, + self.request.user, + TaskActivityAction.UPDATED, + metadata={"fields": changed_fields}, + ) + send_task_board_update(task.board, "updated", "task", task.id) + + def perform_destroy(self, instance): + board = instance.board + task_id = instance.id + instance.delete() + send_task_board_update(board, "deleted", "task", task_id) + + @action(detail=True, methods=["post"]) + def move(self, request, pk=None): + task = self.get_object() + column_id = request.data.get("column") + position = request.data.get("position") + if not column_id: + return Response( + {"column": ["Это поле обязательно."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + column = TaskColumn.objects.get( + id=column_id, + board_id=task.board_id, + is_archived=False, + ) + except TaskColumn.DoesNotExist: + return Response( + {"column": ["Колонка не найдена на этой доске."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + old_column = task.column + if position in (None, ""): + max_position = ( + Task.objects.filter(column=column).exclude(id=task.id).aggregate( + Max("position") + )["position__max"] + or 0 + ) + position = max_position + 1000 + else: + try: + position = int(position) + except (TypeError, ValueError): + return Response( + {"position": ["Позиция должна быть числом."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + task.column = column + task.position = position + task.save(update_fields=["column", "position", "completed_at", "updated_at"]) + + normalize_column_positions(column) + if old_column.id != column.id: + normalize_column_positions(old_column) + task.refresh_from_db() + + serializer = self.get_serializer(task) + create_task_activity( + task, + request.user, + TaskActivityAction.MOVED, + metadata={ + "from_column_id": old_column.id, + "from_column": old_column.name, + "to_column_id": column.id, + "to_column": column.name, + }, + ) + send_task_board_update( + task.board, + "moved", + "task", + task.id, + extra={"column_id": task.column_id}, + ) + return Response(serializer.data) + + @action(detail=True, methods=["get"], url_path="activity") + def activity(self, request, pk=None): + task = self.get_object() + queryset = task.activities.select_related("actor").order_by("-created_at", "-id") + serializer = TaskActivitySerializer(queryset, many=True) + return Response(serializer.data) + + def _message_links_queryset(self, task): + return ( + TaskLinkedObject.objects.filter( + task=task, + kind=TaskLinkedObjectKind.MESSAGE, + ) + .select_related("created_by", "content_type") + .order_by("-created_at", "-id") + ) + + def _event_links_queryset(self, task): + return ( + TaskLinkedObject.objects.filter( + task=task, + kind=TaskLinkedObjectKind.CALENDAR_EVENT, + ) + .select_related("created_by", "content_type") + .order_by("-created_at", "-id") + ) + + @action(detail=True, methods=["get", "post"], url_path="linked-messages") + def linked_messages(self, request, pk=None): + task = self.get_object() + if request.method == "GET": + serializer = TaskLinkedMessageSerializer( + self._message_links_queryset(task), + many=True, + context={"request": request}, + ) + return Response(serializer.data) + + message_id = request.data.get("message_id") + try: + message_id = int(message_id) + except (TypeError, ValueError): + return Response( + {"message_id": ["Укажите ID сообщения."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + message = ( + Message.objects.select_related( + "author", + "chat", + "reply_to", + "reply_to__author", + "poll", + ) + .prefetch_related( + "attachments", + "reactions", + "reactions__user", + "poll__options", + ) + .get(id=message_id) + ) + except Message.DoesNotExist: + return Response( + {"message_id": ["Сообщение не найдено."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if message.is_deleted: + return Response( + {"message_id": ["Удаленное сообщение нельзя связать с задачей."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not user_can_access_chat(message.chat, request.user): + return Response( + {"detail": "Нет доступа к этому сообщению."}, + status=status.HTTP_403_FORBIDDEN, + ) + + link, created = TaskLinkedObject.objects.get_or_create( + task=task, + kind=TaskLinkedObjectKind.MESSAGE, + content_type=get_message_content_type(), + object_id=message.id, + defaults={"created_by": request.user}, + ) + serializer = TaskLinkedMessageSerializer( + link, + context={"request": request}, + ) + if created: + create_task_activity( + task, + request.user, + TaskActivityAction.LINKED, + object_kind=TaskLinkedObjectKind.MESSAGE, + object_id=message.id, + metadata={ + "object_label": message.content[:120], + "object_type": "Сообщение", + }, + ) + send_task_board_update( + task.board, + "linked", + "message", + message.id, + extra={"task_id": task.id, "link_id": link.id}, + ) + return Response( + serializer.data, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + @action( + detail=True, + methods=["delete"], + url_path=r"linked-messages/(?P\d+)", + ) + def unlink_message(self, request, pk=None, link_id=None): + task = self.get_object() + try: + link = self._message_links_queryset(task).get(id=link_id) + except TaskLinkedObject.DoesNotExist: + return Response( + {"detail": "Связь не найдена."}, + status=status.HTTP_404_NOT_FOUND, + ) + + message_id = link.object_id + link.delete() + create_task_activity( + task, + request.user, + TaskActivityAction.UNLINKED, + object_kind=TaskLinkedObjectKind.MESSAGE, + object_id=message_id, + metadata={"object_type": "Сообщение"}, + ) + send_task_board_update( + task.board, + "unlinked", + "message", + message_id, + extra={"task_id": task.id, "link_id": int(link_id)}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=True, methods=["get", "post"], url_path="linked-events") + def linked_events(self, request, pk=None): + task = self.get_object() + if request.method == "GET": + serializer = TaskLinkedCalendarEventSerializer( + self._event_links_queryset(task), + many=True, + context={"request": request}, + ) + return Response(serializer.data) + + event_id = request.data.get("event_id") + try: + event_id = int(event_id) + except (TypeError, ValueError): + return Response( + {"event_id": ["Укажите ID события."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + event = Event.objects.select_related( + "calendar", + "calendar__binding", + "calendar__binding__context_content_type", + "rule", + "creator", + ).get(id=event_id) + except Event.DoesNotExist: + return Response( + {"event_id": ["Событие не найдено."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_calendar_event(request.user, event): + return Response( + {"detail": "Нет доступа к этому событию."}, + status=status.HTTP_403_FORBIDDEN, + ) + + link, created = TaskLinkedObject.objects.get_or_create( + task=task, + kind=TaskLinkedObjectKind.CALENDAR_EVENT, + content_type=get_calendar_event_content_type(), + object_id=event.id, + defaults={"created_by": request.user}, + ) + serializer = TaskLinkedCalendarEventSerializer( + link, + context={"request": request}, + ) + if created: + create_task_activity( + task, + request.user, + TaskActivityAction.LINKED, + object_kind=TaskLinkedObjectKind.CALENDAR_EVENT, + object_id=event.id, + metadata={ + "object_label": event.title, + "object_type": "Календарное событие", + }, + ) + send_task_board_update( + task.board, + "linked", + "calendar_event", + event.id, + extra={"task_id": task.id, "link_id": link.id}, + ) + return Response( + serializer.data, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + @action( + detail=True, + methods=["delete"], + url_path=r"linked-events/(?P\d+)", + ) + def unlink_event(self, request, pk=None, link_id=None): + task = self.get_object() + try: + link = self._event_links_queryset(task).get(id=link_id) + except TaskLinkedObject.DoesNotExist: + return Response( + {"detail": "Связь не найдена."}, + status=status.HTTP_404_NOT_FOUND, + ) + + event_id = link.object_id + link.delete() + create_task_activity( + task, + request.user, + TaskActivityAction.UNLINKED, + object_kind=TaskLinkedObjectKind.CALENDAR_EVENT, + object_id=event_id, + metadata={"object_type": "Календарное событие"}, + ) + send_task_board_update( + task.board, + "unlinked", + "calendar_event", + event_id, + extra={"task_id": task.id, "link_id": int(link_id)}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index f8a6620e..e7c5e377 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -179,6 +179,8 @@ urlpatterns = [ path("notifications/", include("notifications.api.urls")), # Procurement API path("procurement/", include("api.v1.procurement.urls")), + # Tasks API + path("tasks/", include("api.v1.tasks.urls")), # Guests API path("guests/", include("api.v1.guests.urls")), # Search API diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 9933301b..60c264ac 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -749,7 +749,14 @@ class ChatViewSet(viewsets.ModelViewSet): self._auto_mark_read(chat, request.user, messages) return Response({ - 'messages': [serialize_message(m) for m in messages], + 'messages': [ + serialize_message( + m, + user=request.user, + include_linked_tasks=True, + ) + for m in messages + ], 'has_more': has_more }) @@ -831,7 +838,14 @@ class ChatViewSet(viewsets.ModelViewSet): self._auto_mark_read(chat, request.user, messages) return Response({ - 'messages': [serialize_message(m) for m in messages], + 'messages': [ + serialize_message( + m, + user=request.user, + include_linked_tasks=True, + ) + for m in messages + ], 'messages_count': len(messages), 'anchor_id': None, 'anchor_index': 0, @@ -893,7 +907,14 @@ class ChatViewSet(viewsets.ModelViewSet): self._auto_mark_read(chat, request.user, messages) return Response({ - 'messages': [serialize_message(m) for m in messages], + 'messages': [ + serialize_message( + m, + user=request.user, + include_linked_tasks=True, + ) + for m in messages + ], 'messages_count': len(messages), 'anchor_id': None, 'anchor_index': 0, @@ -921,7 +942,14 @@ class ChatViewSet(viewsets.ModelViewSet): self._auto_mark_read(chat, request.user, messages) return Response({ - 'messages': [serialize_message(m) for m in messages], + 'messages': [ + serialize_message( + m, + user=request.user, + include_linked_tasks=True, + ) + for m in messages + ], 'messages_count': len(messages), 'anchor_id': anchor_msg.id, 'anchor_index': anchor_index, @@ -1253,6 +1281,11 @@ class MessageViewSet(viewsets.ModelViewSet): # Отправляем через WebSocket channel_layer = get_channel_layer() payload = serialize_message(message) + response_payload = serialize_message( + message, + user=request.user, + include_linked_tasks=True, + ) async_to_sync(channel_layer.group_send)( f'chat_{chat.id}', @@ -1265,7 +1298,7 @@ class MessageViewSet(viewsets.ModelViewSet): return Response({ 'ok': True, - 'message': payload + 'message': response_payload }, status=status.HTTP_201_CREATED) @action(detail=False, methods=['post'], url_path='upload-temp') @@ -1417,6 +1450,11 @@ class MessageViewSet(viewsets.ModelViewSet): # WebSocket уведомление channel_layer = get_channel_layer() payload = serialize_message(instance) + response_payload = serialize_message( + instance, + user=request.user, + include_linked_tasks=True, + ) group_name = f'chat_{instance.chat_id}' @@ -1429,7 +1467,7 @@ class MessageViewSet(viewsets.ModelViewSet): } ) - return Response(payload) + return Response(response_payload) def destroy(self, request, *args, **kwargs): """Мягкое удаление сообщения""" diff --git a/backend/communications/serialization.py b/backend/communications/serialization.py index dfe32938..07283efb 100644 --- a/backend/communications/serialization.py +++ b/backend/communications/serialization.py @@ -142,7 +142,55 @@ def _get_message_read_by(m) -> list[dict]: return readers -def serialize_message(m) -> dict: +def _get_message_linked_tasks(m, user) -> list[dict]: + if not user or not getattr(user, "is_authenticated", False): + return [] + + try: + from django.contrib.contenttypes.models import ContentType + from tasks.access import task_board_access_q + from tasks.models import ( + TaskBoard, + TaskLinkedObject, + TaskLinkedObjectKind, + ) + except Exception: + return [] + + content_type = ContentType.objects.get_for_model(m.__class__) + accessible_boards = TaskBoard.objects.filter( + is_archived=False, + ).filter(task_board_access_q(user)) + + links = ( + TaskLinkedObject.objects.filter( + kind=TaskLinkedObjectKind.MESSAGE, + content_type=content_type, + object_id=m.id, + task__board__in=accessible_boards, + ) + .select_related("task", "task__board", "task__column") + .order_by("task__title", "task_id") + ) + + return [ + { + "link_id": link.id, + "id": link.task_id, + "title": link.task.title, + "board_id": link.task.board_id, + "board_name": link.task.board.name, + "column_id": link.task.column_id, + "column_name": link.task.column.name, + "column_color": link.task.column.color, + "priority": link.task.priority, + "priority_display": link.task.get_priority_display(), + } + for link in links + ] + + +def serialize_message(m, user=None, include_linked_tasks=False) -> dict: """ Сериализация сообщения с поддержкой всех полей. @@ -181,6 +229,8 @@ def serialize_message(m) -> dict: "has_attachments": m.has_attachments, } data["read_count"] = len(data["read_by"]) + if include_linked_tasks: + data["linked_tasks"] = _get_message_linked_tasks(m, user) # Информация о пересылке (используем forward_metadata) if m.is_forwarded: diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 8e575b9f..f6e51fec 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -68,6 +68,7 @@ INSTALLED_APPS = [ "search.apps.SearchConfig", "finance.apps.FinanceConfig", "procurement.apps.ProcurementConfig", + "tasks.apps.TasksConfig", "guests.apps.GuestsConfig", "push_notifications", # django-push-notifications для Web Push ] diff --git a/backend/realtime/consumers.py b/backend/realtime/consumers.py index 54affc03..818c724c 100644 --- a/backend/realtime/consumers.py +++ b/backend/realtime/consumers.py @@ -319,3 +319,16 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer): "data": event.get("data", {}), } ) + + async def task_board_update(self, event): + """ + Обновление доски задач. + Вызывается из tasks/realtime.py + """ + await self.send_json( + { + "type": "task_board_update", + "event": event.get("event", "updated"), + "data": event.get("data", {}), + } + ) diff --git a/backend/tasks/__init__.py b/backend/tasks/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/tasks/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/tasks/access.py b/backend/tasks/access.py new file mode 100644 index 00000000..1bfd9dde --- /dev/null +++ b/backend/tasks/access.py @@ -0,0 +1,65 @@ +from django.db.models import Q +from employees.models import Department, EmployeeDepartment, RoleAssignment +from schedule.models import Event +from scheduling.services import user_can_view_calendar + +from .models import TaskBoard + + +def get_user_department_ids(user) -> list[int]: + if not user or not user.is_authenticated: + return [] + + department_ids = set( + EmployeeDepartment.objects.filter( + employee_id=user.id, + is_active=True, + ).values_list("department_id", flat=True) + ) + department_ids.update( + Department.objects.filter(head_id=user.id).values_list("id", flat=True) + ) + department_ids.update( + RoleAssignment.objects.filter( + employee_id=user.id, + is_active=True, + ).values_list("role__department_id", flat=True) + ) + return sorted(department_ids) + + +def task_board_access_q(user): + if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): + return Q() + + department_ids = get_user_department_ids(user) + q = Q(created_by=user) + q |= Q(members=user) + if department_ids: + q |= Q(departments__in=department_ids) + q |= Q(members__isnull=True, departments__isnull=True) + return q + + +def user_can_access_task_board(user, board: TaskBoard | None) -> bool: + if not user or not user.is_authenticated or board is None: + return False + if user.is_staff or user.is_superuser: + return True + if board.created_by_id == user.id: + return True + if board.members.filter(id=user.id).exists(): + return True + department_ids = get_user_department_ids(user) + if department_ids and board.departments.filter(id__in=department_ids).exists(): + return True + return ( + not board.members.exists() + and not board.departments.exists() + ) + + +def user_can_access_calendar_event(user, event: Event | None) -> bool: + if not user or not user.is_authenticated or event is None: + return False + return user_can_view_calendar(user, event.calendar) diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py new file mode 100644 index 00000000..0f74ac47 --- /dev/null +++ b/backend/tasks/admin.py @@ -0,0 +1,75 @@ +from django.contrib import admin + +from .models import ( + Task, + TaskActivity, + TaskBoard, + TaskColumn, + TaskLabel, + TaskLinkedObject, +) + + +class TaskColumnInline(admin.TabularInline): + model = TaskColumn + extra = 0 + fields = ["name", "position", "color", "is_done", "is_archived"] + + +class TaskLabelInline(admin.TabularInline): + model = TaskLabel + extra = 0 + fields = ["name", "color"] + + +@admin.register(TaskBoard) +class TaskBoardAdmin(admin.ModelAdmin): + list_display = ["name", "created_by", "is_archived", "created_at"] + list_filter = ["is_archived", "created_at"] + search_fields = ["name", "description"] + filter_horizontal = ["members", "departments"] + inlines = [TaskColumnInline, TaskLabelInline] + + +@admin.register(TaskColumn) +class TaskColumnAdmin(admin.ModelAdmin): + list_display = ["name", "board", "position", "is_done", "is_archived"] + list_filter = ["is_done", "is_archived", "board"] + search_fields = ["name", "board__name"] + + +@admin.register(TaskLabel) +class TaskLabelAdmin(admin.ModelAdmin): + list_display = ["name", "board", "color"] + list_filter = ["board"] + search_fields = ["name", "board__name"] + + +@admin.register(Task) +class TaskAdmin(admin.ModelAdmin): + list_display = [ + "title", + "board", + "column", + "assignee", + "priority", + "due_date", + "completed_at", + ] + list_filter = ["board", "column", "priority", "due_date"] + search_fields = ["title", "description", "assignee__email"] + filter_horizontal = ["labels"] + + +@admin.register(TaskLinkedObject) +class TaskLinkedObjectAdmin(admin.ModelAdmin): + list_display = ["task", "kind", "content_type", "object_id", "created_by", "created_at"] + list_filter = ["kind", "content_type", "created_at"] + search_fields = ["task__title", "created_by__email", "object_id"] + + +@admin.register(TaskActivity) +class TaskActivityAdmin(admin.ModelAdmin): + list_display = ["task", "action", "actor", "object_kind", "object_id", "created_at"] + list_filter = ["action", "object_kind", "created_at"] + search_fields = ["task__title", "actor__email", "object_id"] diff --git a/backend/tasks/apps.py b/backend/tasks/apps.py new file mode 100644 index 00000000..5780cdb6 --- /dev/null +++ b/backend/tasks/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class TasksConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "tasks" + verbose_name = "Задачи" diff --git a/backend/tasks/migrations/0001_initial.py b/backend/tasks/migrations/0001_initial.py new file mode 100644 index 00000000..dbefd542 --- /dev/null +++ b/backend/tasks/migrations/0001_initial.py @@ -0,0 +1,110 @@ +# Generated by Django 5.2.4 on 2026-07-06 07:50 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('employees', '0054_employee_id_guest_range_guard'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TaskBoard', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Название')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('is_archived', models.BooleanField(default=False, verbose_name='Архивная')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_task_boards', to=settings.AUTH_USER_MODEL, verbose_name='Создал')), + ('departments', models.ManyToManyField(blank=True, related_name='task_boards', to='employees.department', verbose_name='Отделы')), + ('members', models.ManyToManyField(blank=True, related_name='task_boards', to=settings.AUTH_USER_MODEL, verbose_name='Участники')), + ], + options={ + 'verbose_name': 'Доска задач', + 'verbose_name_plural': 'Доски задач', + 'ordering': ['name', 'id'], + }, + ), + migrations.CreateModel( + name='TaskColumn', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120, verbose_name='Название')), + ('position', models.PositiveIntegerField(default=0, verbose_name='Позиция')), + ('color', models.CharField(blank=True, max_length=32, verbose_name='Цвет')), + ('is_done', models.BooleanField(default=False, verbose_name='Финальная колонка')), + ('is_archived', models.BooleanField(default=False, verbose_name='Архивная')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), + ('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='columns', to='tasks.taskboard', verbose_name='Доска')), + ], + options={ + 'verbose_name': 'Колонка задач', + 'verbose_name_plural': 'Колонки задач', + 'ordering': ['position', 'id'], + }, + ), + migrations.CreateModel( + name='TaskLabel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=80, verbose_name='Название')), + ('color', models.CharField(default='#38bdf8', max_length=32, verbose_name='Цвет')), + ('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to='tasks.taskboard', verbose_name='Доска')), + ], + options={ + 'verbose_name': 'Метка задачи', + 'verbose_name_plural': 'Метки задач', + 'ordering': ['name', 'id'], + }, + ), + migrations.CreateModel( + name='Task', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Название')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('priority', models.CharField(choices=[('low', 'Низкий'), ('medium', 'Средний'), ('high', 'Высокий'), ('critical', 'Критический')], default='medium', max_length=20, verbose_name='Приоритет')), + ('due_date', models.DateField(blank=True, null=True, verbose_name='Срок')), + ('position', models.PositiveIntegerField(default=0, verbose_name='Позиция')), + ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Завершено')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), + ('assignee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tasks', to=settings.AUTH_USER_MODEL, verbose_name='Исполнитель')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_tasks', to=settings.AUTH_USER_MODEL, verbose_name='Создал')), + ('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='tasks.taskboard', verbose_name='Доска')), + ('column', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tasks', to='tasks.taskcolumn', verbose_name='Колонка')), + ('labels', models.ManyToManyField(blank=True, related_name='tasks', to='tasks.tasklabel', verbose_name='Метки')), + ], + options={ + 'verbose_name': 'Задача', + 'verbose_name_plural': 'Задачи', + 'ordering': ['position', '-created_at', 'id'], + }, + ), + migrations.AddConstraint( + model_name='taskcolumn', + constraint=models.UniqueConstraint(fields=('board', 'name'), name='uniq_task_column_board_name'), + ), + migrations.AddConstraint( + model_name='tasklabel', + constraint=models.UniqueConstraint(fields=('board', 'name'), name='uniq_task_label_board_name'), + ), + migrations.AddIndex( + model_name='task', + index=models.Index(fields=['board', 'column', 'position'], name='tasks_task_board_i_6021b0_idx'), + ), + migrations.AddIndex( + model_name='task', + index=models.Index(fields=['assignee', 'due_date'], name='tasks_task_assigne_5c11c7_idx'), + ), + ] diff --git a/backend/tasks/migrations/0002_tasklinkedobject.py b/backend/tasks/migrations/0002_tasklinkedobject.py new file mode 100644 index 00000000..d71be578 --- /dev/null +++ b/backend/tasks/migrations/0002_tasklinkedobject.py @@ -0,0 +1,107 @@ +# Generated by Codex on 2026-07-06 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("tasks", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="TaskLinkedObject", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "kind", + models.CharField( + choices=[("message", "Сообщение")], + max_length=32, + verbose_name="Тип связи", + ), + ), + ("object_id", models.PositiveBigIntegerField(verbose_name="ID объекта")), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + verbose_name="Создано", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, + default=dict, + verbose_name="Метаданные", + ), + ), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + verbose_name="Тип объекта", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="created_task_links", + to=settings.AUTH_USER_MODEL, + verbose_name="Связал", + ), + ), + ( + "task", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="linked_objects", + to="tasks.task", + verbose_name="Задача", + ), + ), + ], + options={ + "verbose_name": "Связанный объект задачи", + "verbose_name_plural": "Связанные объекты задач", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.AddConstraint( + model_name="tasklinkedobject", + constraint=models.UniqueConstraint( + fields=("task", "content_type", "object_id"), + name="uniq_task_linked_object", + ), + ), + migrations.AddIndex( + model_name="tasklinkedobject", + index=models.Index( + fields=["task", "kind"], + name="tasks_taskl_task_id_21eadf_idx", + ), + ), + migrations.AddIndex( + model_name="tasklinkedobject", + index=models.Index( + fields=["content_type", "object_id"], + name="tasks_taskl_content_362991_idx", + ), + ), + ] diff --git a/backend/tasks/migrations/0003_alter_tasklinkedobject_kind.py b/backend/tasks/migrations/0003_alter_tasklinkedobject_kind.py new file mode 100644 index 00000000..6dc0055c --- /dev/null +++ b/backend/tasks/migrations/0003_alter_tasklinkedobject_kind.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2026-07-06 10:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0002_tasklinkedobject'), + ] + + operations = [ + migrations.AlterField( + model_name='tasklinkedobject', + name='kind', + field=models.CharField(choices=[('message', 'Сообщение'), ('calendar_event', 'Календарное событие')], max_length=32, verbose_name='Тип связи'), + ), + ] diff --git a/backend/tasks/migrations/0004_taskactivity.py b/backend/tasks/migrations/0004_taskactivity.py new file mode 100644 index 00000000..386b6848 --- /dev/null +++ b/backend/tasks/migrations/0004_taskactivity.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.4 on 2026-07-06 10:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0003_alter_tasklinkedobject_kind'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TaskActivity', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(choices=[('created', 'Создал задачу'), ('updated', 'Обновил задачу'), ('moved', 'Переместил задачу'), ('linked', 'Связал объект'), ('unlinked', 'Убрал связь')], max_length=32, verbose_name='Действие')), + ('object_kind', models.CharField(blank=True, choices=[('message', 'Сообщение'), ('calendar_event', 'Календарное событие')], max_length=32, verbose_name='Тип объекта')), + ('object_id', models.PositiveBigIntegerField(blank=True, null=True, verbose_name='ID объекта')), + ('metadata', models.JSONField(blank=True, default=dict, verbose_name='Метаданные')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='task_activities', to=settings.AUTH_USER_MODEL, verbose_name='Исполнитель')), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='tasks.task', verbose_name='Задача')), + ], + options={ + 'verbose_name': 'История задачи', + 'verbose_name_plural': 'История задач', + 'ordering': ['-created_at', '-id'], + 'indexes': [models.Index(fields=['task', '-created_at'], name='tasks_taska_task_id_db425c_idx')], + }, + ), + ] diff --git a/backend/tasks/migrations/__init__.py b/backend/tasks/migrations/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/tasks/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/tasks/models.py b/backend/tasks/models.py new file mode 100644 index 00000000..ca715176 --- /dev/null +++ b/backend/tasks/models.py @@ -0,0 +1,275 @@ +from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.utils import timezone +from employees.models import Department + + +class TaskPriority(models.TextChoices): + LOW = "low", "Низкий" + MEDIUM = "medium", "Средний" + HIGH = "high", "Высокий" + CRITICAL = "critical", "Критический" + + +class TaskBoard(models.Model): + name = models.CharField("Название", max_length=255) + description = models.TextField("Описание", blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="created_task_boards", + verbose_name="Создал", + ) + members = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="task_boards", + verbose_name="Участники", + ) + departments = models.ManyToManyField( + Department, + blank=True, + related_name="task_boards", + verbose_name="Отделы", + ) + is_archived = models.BooleanField("Архивная", default=False) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Доска задач" + verbose_name_plural = "Доски задач" + ordering = ["name", "id"] + + def __str__(self): + return self.name + + +class TaskColumn(models.Model): + board = models.ForeignKey( + TaskBoard, + on_delete=models.CASCADE, + related_name="columns", + verbose_name="Доска", + ) + name = models.CharField("Название", max_length=120) + position = models.PositiveIntegerField("Позиция", default=0) + color = models.CharField("Цвет", max_length=32, blank=True) + is_done = models.BooleanField("Финальная колонка", default=False) + is_archived = models.BooleanField("Архивная", default=False) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Колонка задач" + verbose_name_plural = "Колонки задач" + ordering = ["position", "id"] + constraints = [ + models.UniqueConstraint( + fields=["board", "name"], + name="uniq_task_column_board_name", + ) + ] + + def __str__(self): + return f"{self.board}: {self.name}" + + +class TaskLabel(models.Model): + board = models.ForeignKey( + TaskBoard, + on_delete=models.CASCADE, + related_name="labels", + verbose_name="Доска", + ) + name = models.CharField("Название", max_length=80) + color = models.CharField("Цвет", max_length=32, default="#38bdf8") + + class Meta: + verbose_name = "Метка задачи" + verbose_name_plural = "Метки задач" + ordering = ["name", "id"] + constraints = [ + models.UniqueConstraint( + fields=["board", "name"], + name="uniq_task_label_board_name", + ) + ] + + def __str__(self): + return self.name + + +class Task(models.Model): + board = models.ForeignKey( + TaskBoard, + on_delete=models.CASCADE, + related_name="tasks", + verbose_name="Доска", + ) + column = models.ForeignKey( + TaskColumn, + on_delete=models.PROTECT, + related_name="tasks", + verbose_name="Колонка", + ) + title = models.CharField("Название", max_length=255) + description = models.TextField("Описание", blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="created_tasks", + verbose_name="Создал", + ) + assignee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="assigned_tasks", + verbose_name="Исполнитель", + ) + labels = models.ManyToManyField( + TaskLabel, + blank=True, + related_name="tasks", + verbose_name="Метки", + ) + priority = models.CharField( + "Приоритет", + max_length=20, + choices=TaskPriority.choices, + default=TaskPriority.MEDIUM, + ) + due_date = models.DateField("Срок", null=True, blank=True) + position = models.PositiveIntegerField("Позиция", default=0) + completed_at = models.DateTimeField("Завершено", null=True, blank=True) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Задача" + verbose_name_plural = "Задачи" + ordering = ["position", "-created_at", "id"] + indexes = [ + models.Index(fields=["board", "column", "position"]), + models.Index(fields=["assignee", "due_date"]), + ] + + def __str__(self): + return self.title + + def save(self, *args, **kwargs): + if self.column_id and self.board_id != self.column.board_id: + self.board_id = self.column.board_id + + if self.column_id and self.column.is_done and not self.completed_at: + self.completed_at = timezone.now() + elif self.column_id and not self.column.is_done: + self.completed_at = None + + super().save(*args, **kwargs) + + +class TaskLinkedObjectKind(models.TextChoices): + MESSAGE = "message", "Сообщение" + CALENDAR_EVENT = "calendar_event", "Календарное событие" + + +class TaskActivityAction(models.TextChoices): + CREATED = "created", "Создал задачу" + UPDATED = "updated", "Обновил задачу" + MOVED = "moved", "Переместил задачу" + LINKED = "linked", "Связал объект" + UNLINKED = "unlinked", "Убрал связь" + + +class TaskActivity(models.Model): + task = models.ForeignKey( + Task, + on_delete=models.CASCADE, + related_name="activities", + verbose_name="Задача", + ) + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="task_activities", + verbose_name="Исполнитель", + ) + action = models.CharField( + "Действие", + max_length=32, + choices=TaskActivityAction.choices, + ) + object_kind = models.CharField( + "Тип объекта", + max_length=32, + choices=TaskLinkedObjectKind.choices, + blank=True, + ) + object_id = models.PositiveBigIntegerField("ID объекта", null=True, blank=True) + metadata = models.JSONField("Метаданные", default=dict, blank=True) + created_at = models.DateTimeField("Создано", auto_now_add=True) + + class Meta: + verbose_name = "История задачи" + verbose_name_plural = "История задач" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["task", "-created_at"]), + ] + + def __str__(self): + return f"{self.task} - {self.action}" + + +class TaskLinkedObject(models.Model): + task = models.ForeignKey( + Task, + on_delete=models.CASCADE, + related_name="linked_objects", + verbose_name="Задача", + ) + kind = models.CharField( + "Тип связи", + max_length=32, + choices=TaskLinkedObjectKind.choices, + ) + content_type = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + verbose_name="Тип объекта", + ) + object_id = models.PositiveBigIntegerField("ID объекта") + content_object = GenericForeignKey("content_type", "object_id") + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="created_task_links", + verbose_name="Связал", + ) + created_at = models.DateTimeField("Создано", auto_now_add=True) + metadata = models.JSONField("Метаданные", default=dict, blank=True) + + class Meta: + verbose_name = "Связанный объект задачи" + verbose_name_plural = "Связанные объекты задач" + ordering = ["-created_at", "-id"] + constraints = [ + models.UniqueConstraint( + fields=["task", "content_type", "object_id"], + name="uniq_task_linked_object", + ) + ] + indexes = [ + models.Index(fields=["task", "kind"]), + models.Index(fields=["content_type", "object_id"]), + ] + + def __str__(self): + return f"{self.task} -> {self.kind}:{self.object_id}" diff --git a/backend/tasks/realtime.py b/backend/tasks/realtime.py new file mode 100644 index 00000000..fccecee3 --- /dev/null +++ b/backend/tasks/realtime.py @@ -0,0 +1,126 @@ +import logging +from collections.abc import Iterable + +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.contrib.auth import get_user_model +from django.db import transaction +from django.db.models import Q + +from employees.models import Department, EmployeeDepartment, RoleAssignment + +from .models import TaskBoard + +logger = logging.getLogger(__name__) + + +def get_task_board_recipient_ids(board: TaskBoard) -> set[int]: + if not board or not board.pk: + return set() + + User = get_user_model() + admin_q = Q(is_staff=True) | Q(is_superuser=True) + + if not board.members.exists() and not board.departments.exists(): + return set( + User.objects.filter(is_active=True) + .values_list("id", flat=True) + ) + + department_ids = list(board.departments.values_list("id", flat=True)) + recipient_ids = {board.created_by_id} + recipient_ids.update(board.members.values_list("id", flat=True)) + + if department_ids: + recipient_ids.update( + EmployeeDepartment.objects.filter( + department_id__in=department_ids, + is_active=True, + employee__is_active=True, + ).values_list("employee_id", flat=True) + ) + recipient_ids.update( + Department.objects.filter( + id__in=department_ids, + head__is_active=True, + ).values_list("head_id", flat=True) + ) + recipient_ids.update( + RoleAssignment.objects.filter( + role__department_id__in=department_ids, + is_active=True, + employee__is_active=True, + ).values_list("employee_id", flat=True) + ) + + recipient_ids.update( + User.objects.filter(is_active=True) + .filter(admin_q) + .values_list("id", flat=True) + ) + + return set( + User.objects.filter( + id__in={user_id for user_id in recipient_ids if user_id}, + is_active=True, + ).values_list("id", flat=True) + ) + + +def send_task_board_update( + board: TaskBoard | None, + event: str, + model: str, + object_id: int | None = None, + *, + recipient_ids: Iterable[int] | None = None, + board_id: int | None = None, + extra: dict | None = None, +) -> None: + resolved_board_id = board_id or getattr(board, "id", None) + if not resolved_board_id: + return + + recipients = set( + recipient_ids + or ( + get_task_board_recipient_ids(board) + if board and board.pk + else set() + ) + ) + if not recipients: + return + + payload = { + "board_id": resolved_board_id, + "event": event, + "model": model, + "object_id": object_id, + } + if extra: + payload.update(extra) + + def _send() -> None: + channel_layer = get_channel_layer() + if channel_layer is None: + return + + message = { + "type": "task_board_update", + "event": event, + "data": payload, + } + for user_id in recipients: + try: + async_to_sync(channel_layer.group_send)( + f"user_{user_id}", + message, + ) + except Exception: + logger.exception( + "Failed to send task board update to user %s", + user_id, + ) + + transaction.on_commit(_send) diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py new file mode 100644 index 00000000..987fc31b --- /dev/null +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -0,0 +1,521 @@ +from unittest.mock import patch +from datetime import timedelta + +import pytest +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from communications.models import Chat, ChatMembership, Message +from employees.models import Department, EmployeeDepartment, RoleAssignment +from schedule.models import Calendar, CalendarRelation, Event +from tasks.models import Task, TaskBoard, TaskColumn +from tasks.realtime import get_task_board_recipient_ids + +pytestmark = pytest.mark.django_db + +User = get_user_model() + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def user(): + return User.objects.create_user( + email="task-user@example.com", + password="testpass123", + phone_number="+79994440001", + first_name="Иван", + last_name="Задачев", + is_active=True, + email_verified=True, + send_activation_email=False, + ) + + +def make_user(email, phone_number, **extra): + defaults = { + "password": "testpass123", + "phone_number": phone_number, + "first_name": "Тест", + "last_name": "Сотрудник", + "is_active": True, + "email_verified": True, + "send_activation_email": False, + } + defaults.update(extra) + return User.objects.create_user(email=email, **defaults) + + +def test_default_board_creates_work_columns(api_client, user): + api_client.force_authenticate(user=user) + url = reverse("api:v1:tasks:task-board-default") + + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["name"] == "Рабочая доска" + assert [column["name"] for column in response.data["columns"]] == [ + "Новые", + "В работе", + "На проверке", + "Готово", + ] + assert TaskBoard.objects.count() == 1 + assert TaskColumn.objects.count() == 4 + + +def test_create_task_and_move_to_done_column(api_client, user): + api_client.force_authenticate(user=user) + board_response = api_client.get( + reverse("api:v1:tasks:task-board-default") + ) + board_id = board_response.data["id"] + columns = {column["name"]: column for column in board_response.data["columns"]} + + create_response = api_client.post( + reverse("api:v1:tasks:task-list"), + { + "board": board_id, + "column": columns["Новые"]["id"], + "title": "Подготовить прототип доски", + "description": "Собрать первый рабочий workflow", + "assignee_id": user.id, + "priority": "high", + }, + format="json", + ) + + assert create_response.status_code == status.HTTP_201_CREATED + task_id = create_response.data["id"] + assert create_response.data["created_by"]["id"] == user.id + assert create_response.data["assignee"]["id"] == user.id + + with patch("api.v1.tasks.views.send_task_board_update") as send_update: + move_response = api_client.post( + reverse("api:v1:tasks:task-move", kwargs={"pk": task_id}), + {"column": columns["Готово"]["id"]}, + format="json", + ) + + assert move_response.status_code == status.HTTP_200_OK + assert move_response.data["column"] == columns["Готово"]["id"] + assert move_response.data["completed_at"] is not None + send_update.assert_called_once() + send_args, send_kwargs = send_update.call_args + assert send_args[0].id == board_id + assert send_args[1:4] == ("moved", "task", task_id) + assert send_kwargs["extra"] == {"column_id": columns["Готово"]["id"]} + + task = Task.objects.get(id=task_id) + assert task.column_id == columns["Готово"]["id"] + assert task.completed_at is not None + + +def test_task_rejects_column_from_another_board(api_client, user): + api_client.force_authenticate(user=user) + first = api_client.get(reverse("api:v1:tasks:task-board-default")).data + second_board = TaskBoard.objects.create( + name="Вторая доска", + created_by=user, + ) + other_column = TaskColumn.objects.create( + board=second_board, + name="Чужая колонка", + position=1000, + ) + + response = api_client.post( + reverse("api:v1:tasks:task-list"), + { + "board": first["id"], + "column": other_column.id, + "title": "Невалидная задача", + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "column" in response.data + + +def test_create_board_accepts_members_and_departments(api_client, user): + member = make_user("task-member@example.com", "+79994440002") + department = Department.objects.create(name="Проектный отдел") + api_client.force_authenticate(user=user) + + response = api_client.post( + reverse("api:v1:tasks:task-board-list"), + { + "name": "Приватная доска", + "description": "Только для проектной группы", + "members": [member.id], + "departments": [department.id], + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["members"] == [member.id] + assert response.data["member_details"][0]["id"] == member.id + assert response.data["departments"] == [department.id] + assert response.data["department_details"][0]["name"] == department.name + assert TaskColumn.objects.filter(board_id=response.data["id"]).count() == 4 + + +def test_board_member_can_access_private_board(api_client): + owner = make_user("task-owner@example.com", "+79994440003") + member = make_user("task-member-2@example.com", "+79994440004") + outsider = make_user("task-outsider@example.com", "+79994440005") + board = TaskBoard.objects.create(name="Закрытая доска", created_by=owner) + board.members.add(member) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + + api_client.force_authenticate(user=member) + member_list = api_client.get(reverse("api:v1:tasks:task-board-list")) + assert member_list.status_code == status.HTTP_200_OK + assert [item["id"] for item in member_list.data["results"]] == [board.id] + + create_response = api_client.post( + reverse("api:v1:tasks:task-list"), + { + "board": board.id, + "column": column.id, + "title": "Задача участника", + }, + format="json", + ) + assert create_response.status_code == status.HTTP_201_CREATED + + api_client.force_authenticate(user=outsider) + outsider_list = api_client.get(reverse("api:v1:tasks:task-board-list")) + assert outsider_list.status_code == status.HTTP_200_OK + assert [item["id"] for item in outsider_list.data["results"]] == [] + + detail_response = api_client.get( + reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}) + ) + assert detail_response.status_code == status.HTTP_404_NOT_FOUND + + hidden_create_response = api_client.post( + reverse("api:v1:tasks:task-list"), + { + "board": board.id, + "column": column.id, + "title": "Чужая задача", + }, + format="json", + ) + assert hidden_create_response.status_code == status.HTTP_400_BAD_REQUEST + assert "board" in hidden_create_response.data + + +def test_department_member_can_access_department_board(api_client): + owner = make_user("task-board-owner@example.com", "+79994440006") + department_user = make_user("task-department-user@example.com", "+79994440007") + outsider = make_user("task-department-outsider@example.com", "+79994440008") + department = Department.objects.create(name="Отдел внедрения") + EmployeeDepartment.objects.create( + employee=department_user, + department=department, + is_active=True, + ) + board = TaskBoard.objects.create(name="Доска отдела", created_by=owner) + board.departments.add(department) + + api_client.force_authenticate(user=department_user) + department_response = api_client.get(reverse("api:v1:tasks:task-board-list")) + assert department_response.status_code == status.HTTP_200_OK + assert [item["id"] for item in department_response.data["results"]] == [board.id] + + api_client.force_authenticate(user=outsider) + outsider_response = api_client.get(reverse("api:v1:tasks:task-board-list")) + assert outsider_response.status_code == status.HTTP_200_OK + assert [item["id"] for item in outsider_response.data["results"]] == [] + + +def test_task_board_realtime_recipients_match_board_access(): + owner = make_user("task-rt-owner@example.com", "+79994440009") + member = make_user("task-rt-member@example.com", "+79994440010") + department_user = make_user("task-rt-dept@example.com", "+79994440011") + role_user = make_user("task-rt-role@example.com", "+79994440012") + outsider = make_user("task-rt-outsider@example.com", "+79994440013") + admin = make_user( + "task-rt-admin@example.com", + "+79994440014", + is_staff=True, + ) + inactive_member = make_user( + "task-rt-inactive@example.com", + "+79994440015", + is_active=False, + ) + department = Department.objects.create(name="Realtime отдел") + role = department.roles.create(name="Внешний участник") + EmployeeDepartment.objects.create( + employee=department_user, + department=department, + is_active=True, + ) + RoleAssignment.objects.create( + employee=role_user, + role=role, + is_active=True, + ) + board = TaskBoard.objects.create(name="Закрытая realtime доска", created_by=owner) + board.members.add(member, inactive_member) + board.departments.add(department) + + recipients = get_task_board_recipient_ids(board) + + assert owner.id in recipients + assert member.id in recipients + assert department_user.id in recipients + assert role_user.id in recipients + assert admin.id in recipients + assert outsider.id not in recipients + assert inactive_member.id not in recipients + + +def test_public_task_board_realtime_recipients_include_active_users(): + owner = make_user("task-rt-public-owner@example.com", "+79994440016") + active_user = make_user("task-rt-public-user@example.com", "+79994440017") + inactive_user = make_user( + "task-rt-public-inactive@example.com", + "+79994440018", + is_active=False, + ) + board = TaskBoard.objects.create(name="Публичная realtime доска", created_by=owner) + + recipients = get_task_board_recipient_ids(board) + + assert owner.id in recipients + assert active_user.id in recipients + assert inactive_user.id not in recipients + + +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") + board = TaskBoard.objects.create(name="Доска со связями", created_by=owner) + board.members.add(board_member) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + task = Task.objects.create( + board=board, + column=column, + title="Разобрать сообщение", + created_by=owner, + ) + chat = Chat.objects.create(name="Закрытый чат", type="group", created_by=owner) + ChatMembership.objects.create(chat=chat, user=owner, role="admin") + message = Message.objects.create( + chat=chat, + author=owner, + content="Важный контекст для задачи", + ) + + api_client.force_authenticate(user=board_member) + denied_response = api_client.post( + reverse("api:v1:tasks:task-linked-messages", kwargs={"pk": task.id}), + {"message_id": message.id}, + format="json", + ) + assert denied_response.status_code == status.HTTP_403_FORBIDDEN + + api_client.force_authenticate(user=owner) + link_response = api_client.post( + reverse("api:v1:tasks:task-linked-messages", kwargs={"pk": task.id}), + {"message_id": message.id}, + format="json", + ) + assert link_response.status_code == status.HTTP_201_CREATED + assert link_response.data["message"]["content"] == "Важный контекст для задачи" + assert link_response.data["can_open"] is True + assert link_response.data["object_url"] == ( + f"/messages/{chat.id}?message={message.id}" + ) + + api_client.force_authenticate(user=board_member) + linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-messages", kwargs={"pk": task.id}) + ) + assert linked_response.status_code == status.HTTP_200_OK + assert linked_response.data[0]["message_id"] == message.id + assert linked_response.data[0]["message"]["content"] == "Важный контекст для задачи" + assert linked_response.data[0]["can_open"] is False + assert linked_response.data[0]["object_url"] is None + + +def test_task_linked_calendar_event_respects_event_access(api_client): + owner = make_user("task-event-owner@example.com", "+79994440024") + board_member = make_user("task-event-member@example.com", "+79994440025") + board = TaskBoard.objects.create(name="Доска с событиями", created_by=owner) + board.members.add(board_member) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + task = Task.objects.create( + board=board, + column=column, + title="Подготовить встречу", + created_by=owner, + ) + calendar = Calendar.objects.create(name="Закрытый календарь", slug="closed") + CalendarRelation.objects.create( + calendar=calendar, + content_type=ContentType.objects.get_for_model(User), + object_id=owner.id, + distinction="owner", + inheritable=True, + ) + now = timezone.now() + event = Event.objects.create( + calendar=calendar, + title="Встреча по задаче", + description="Обсудить детали", + start=now, + end=now + timedelta(hours=1), + creator=owner, + color_event="#38bdf8", + ) + + api_client.force_authenticate(user=board_member) + denied_response = api_client.post( + reverse("api:v1:tasks:task-linked-events", kwargs={"pk": task.id}), + {"event_id": event.id}, + format="json", + ) + assert denied_response.status_code == status.HTTP_403_FORBIDDEN + + api_client.force_authenticate(user=owner) + link_response = api_client.post( + reverse("api:v1:tasks:task-linked-events", kwargs={"pk": task.id}), + {"event_id": event.id}, + format="json", + ) + assert link_response.status_code == status.HTTP_201_CREATED + assert link_response.data["event"]["title"] == "Встреча по задаче" + assert link_response.data["can_open"] is True + assert link_response.data["object_url"] == ( + f"/calendar?calendar={calendar.id}&event={event.id}" + ) + + api_client.force_authenticate(user=board_member) + linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-events", kwargs={"pk": task.id}) + ) + assert linked_response.status_code == status.HTTP_200_OK + assert linked_response.data[0]["event_id"] == event.id + assert linked_response.data[0]["event"]["title"] == "Встреча по задаче" + assert linked_response.data[0]["can_open"] is False + assert linked_response.data[0]["object_url"] is None + + +def test_task_activity_records_core_actions(api_client, user): + api_client.force_authenticate(user=user) + board_response = api_client.get( + reverse("api:v1:tasks:task-board-default") + ) + board_id = board_response.data["id"] + columns = board_response.data["columns"] + source_column = columns[0] + target_column = columns[1] + + create_response = api_client.post( + reverse("api:v1:tasks:task-list"), + { + "board": board_id, + "column": source_column["id"], + "title": "История задачи", + "description": "", + "priority": "medium", + }, + format="json", + ) + assert create_response.status_code == status.HTTP_201_CREATED + task_id = create_response.data["id"] + + update_response = api_client.patch( + reverse("api:v1:tasks:task-detail", kwargs={"pk": task_id}), + {"title": "История задачи обновлена"}, + format="json", + ) + assert update_response.status_code == status.HTTP_200_OK + + 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 + + activity_response = api_client.get( + reverse("api:v1:tasks:task-activity", kwargs={"pk": task_id}) + ) + assert activity_response.status_code == status.HTTP_200_OK + actions = [item["action"] for item in activity_response.data] + assert actions == ["moved", "updated", "created"] + assert activity_response.data[0]["metadata"]["from_column"] == source_column["name"] + assert activity_response.data[0]["metadata"]["to_column"] == target_column["name"] + assert "title" in activity_response.data[1]["metadata"]["fields"] + + +def test_chat_message_serialization_shows_only_accessible_linked_tasks(api_client): + owner = make_user("task-link-chat-owner@example.com", "+79994440021") + visible_user = make_user("task-link-chat-visible@example.com", "+79994440022") + hidden_user = make_user("task-link-chat-hidden@example.com", "+79994440023") + board = TaskBoard.objects.create(name="Видимая доска", created_by=owner) + board.members.add(visible_user) + column = TaskColumn.objects.create( + board=board, + name="Новые", + position=1000, + color="#16a34a", + ) + task = Task.objects.create( + board=board, + column=column, + title="Задача из сообщения", + created_by=owner, + priority="critical", + ) + chat = Chat.objects.create(name="Общий чат", type="group", created_by=owner) + ChatMembership.objects.create(chat=chat, user=owner, role="admin") + ChatMembership.objects.create(chat=chat, user=visible_user, role="member") + ChatMembership.objects.create(chat=chat, user=hidden_user, role="member") + message = Message.objects.create( + chat=chat, + author=owner, + content="Сообщение с задачей", + ) + + api_client.force_authenticate(user=owner) + api_client.post( + reverse("api:v1:tasks:task-linked-messages", kwargs={"pk": task.id}), + {"message_id": message.id}, + format="json", + ) + + api_client.force_authenticate(user=visible_user) + visible_response = api_client.get( + reverse("api:v1:chats-messages", kwargs={"pk": chat.id}) + ) + assert visible_response.status_code == status.HTTP_200_OK + linked_task = visible_response.data["messages"][0]["linked_tasks"][0] + assert linked_task["id"] == task.id + assert linked_task["column_id"] == column.id + assert linked_task["column_name"] == "Новые" + assert linked_task["column_color"] == "#16a34a" + assert linked_task["priority"] == "critical" + assert linked_task["priority_display"] == "Критический" + + api_client.force_authenticate(user=hidden_user) + hidden_response = api_client.get( + reverse("api:v1:chats-messages", kwargs={"pk": chat.id}) + ) + assert hidden_response.status_code == status.HTTP_200_OK + assert hidden_response.data["messages"][0]["linked_tasks"] == [] diff --git a/backend/tests/realtime/test_user_consumer.py b/backend/tests/realtime/test_user_consumer.py index 7a9ea2f4..bb087bbf 100644 --- a/backend/tests/realtime/test_user_consumer.py +++ b/backend/tests/realtime/test_user_consumer.py @@ -10,7 +10,7 @@ - Открытие/закрытие активного чата """ import pytest -from channels.testing import WebsocketCommunicator +from channels.layers import get_channel_layer from channels.db import database_sync_to_async from django.contrib.auth.models import AnonymousUser @@ -76,6 +76,44 @@ class TestUserConsumerConnection: # После отключения не должно быть ошибок + +@pytest.mark.asyncio +class TestUserConsumerTaskBoardEvents: + """Тесты realtime-событий доски задач.""" + + async def test_task_board_update_event(self, ws_communicator, user): + """Пользователь получает событие обновления доски через личный канал.""" + communicator = await ws_communicator(user=user) + + connected, _ = await communicator.connect() + assert connected + + channel_layer = get_channel_layer() + await channel_layer.group_send( + f"user_{user.id}", + { + "type": "task_board_update", + "event": "moved", + "data": { + "board_id": 15, + "model": "task", + "object_id": 42, + "column_id": 7, + }, + }, + ) + + response = await communicator.receive_json_from(timeout=5) + + assert response["type"] == "task_board_update" + assert response["event"] == "moved" + assert response["data"]["board_id"] == 15 + assert response["data"]["model"] == "task" + assert response["data"]["object_id"] == 42 + assert response["data"]["column_id"] == 7 + + await communicator.disconnect() + @pytest.mark.asyncio class TestUserConsumerChatManagement: """Тесты управления активным чатом.""" diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 615184c7..543de4d6 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -892,3 +892,39 @@ html[data-bs-theme="dark"] .shadow-xl { .app-tabs-scroll::-webkit-scrollbar-thumb:hover { background: var(--accent-primary); } + +.tasks-board-scroll, +.tasks-column-scroll { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--accent-primary) 42%, var(--surface-tertiary)) transparent; + -ms-overflow-style: auto; +} + +.tasks-board-scroll::-webkit-scrollbar, +.tasks-column-scroll::-webkit-scrollbar { + display: block; +} + +.tasks-board-scroll::-webkit-scrollbar { + height: 8px; +} + +.tasks-column-scroll::-webkit-scrollbar { + width: 6px; +} + +.tasks-board-scroll::-webkit-scrollbar-track, +.tasks-column-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.tasks-board-scroll::-webkit-scrollbar-thumb, +.tasks-column-scroll::-webkit-scrollbar-thumb { + border-radius: 9999px; + background: color-mix(in srgb, var(--accent-primary) 38%, var(--surface-tertiary)); +} + +.tasks-board-scroll::-webkit-scrollbar-thumb:hover, +.tasks-column-scroll::-webkit-scrollbar-thumb:hover { + background: var(--accent-primary); +} diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 344881f6..b78c583c 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -2,8 +2,16 @@ import React, { Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react"; import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation"; -import { MessageCircle, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; +import { + MessageCircle, + ChevronDown, + ChevronLeft, + ChevronRight, + Link2, + Loader2, +} from "lucide-react"; import { AppShell } from "../../../components/AppShell"; +import { Modal } from "@/components/ui"; import ChatDialogHeader from "@/components/messages/ChatDialogHeader"; import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal"; import ChatMessageItem, { MediaPreview } from "@/components/messages/ChatMessageItem"; @@ -12,7 +20,15 @@ import MessageReadersModal from "@/components/messages/MessageReadersModal"; import MessageComposer from "@/components/messages/MessageComposer"; import ReactionPickerModal from "@/components/messages/ReactionPickerModal"; import { apiClient } from "@/lib/api"; -import type { ChatMessageSearchResponse, ChatMessageSearchResult, Message } from "@/types/api"; +import type { + ChatMessageSearchResponse, + ChatMessageSearchResult, + Message, + TaskBoard, + TaskCard, + TaskPriority, + User, +} from "@/types/api"; import { useUser } from "@/contexts/UserContext"; import { useChatFallbackSync } from "@/hooks/useChatFallbackSync"; import { useSilentChatReloadGuard } from "@/hooks/useSilentChatReloadGuard"; @@ -22,6 +38,7 @@ import { useMarkRead } from "@/hooks/useMarkRead"; import { useChatScroll, shiftDateInputValue } from "@/hooks/useChatScroll"; import { mergeDisplayMessages, uniqueMessagesById } from "@/lib/messages/chatUtils"; import { formatDayDivider, getMessageDate, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils"; +import { displayUserName } from "@/lib/shared"; import wsManager from "@/lib/websocketManager"; import ScrollableMessageList, { ScrollableMessageListInner } from "@/components/ScrollableMessageList"; @@ -29,6 +46,14 @@ import ScrollableMessageList, { ScrollableMessageListInner } from "@/components/ type ReplyTarget = { id: number; author: string; preview: string }; type TypingUser = { id: number; name: string }; +type TaskLinkMode = "existing" | "create"; + +const taskPriorityOptions: { value: TaskPriority; label: string }[] = [ + { value: "low", label: "Низкая" }, + { value: "medium", label: "Средняя" }, + { value: "high", label: "Высокая" }, + { value: "critical", label: "Критическая" }, +]; /* ─── constants ─── */ const RECENT_REACTIONS_KEY = "eusrr_recent_reactions"; @@ -66,6 +91,14 @@ function formatTypingIndicatorText(users: TypingUser[]): string { return `${users[0].name} и ещё ${additionalCount} ${additionalLabel} печатают...`; } +function getDefaultTaskTitleFromMessage(message: Message): string { + const content = (message.content || "").trim().replace(/\s+/g, " "); + if (content) { + return content.length > 80 ? `${content.slice(0, 77)}...` : content; + } + return `Задача по сообщению #${message.id}`; +} + /* ─── component ─── */ export default function MessageDialogPage() { @@ -103,6 +136,7 @@ function MessageDialogPageContent() { const searchInputRef = useRef(null); const highlightTimerRef = useRef(null); const pendingRetryFilesRef = useRef>(new Map()); + const displayMessageIdsRef = useRef>(new Set()); /* ────────────────────────────────────────────────────── * HOOK: Chat messages (state, pagination, pending) @@ -206,8 +240,28 @@ function MessageDialogPageContent() { const [searchError, setSearchError] = useState(null); const [nextSearchOffset, setNextSearchOffset] = useState(null); const [highlightedMessageId, setHighlightedMessageId] = useState(null); + const [taskLinkMessage, setTaskLinkMessage] = useState(null); + const [taskLinkBoards, setTaskLinkBoards] = useState([]); + const [taskLinkEmployees, setTaskLinkEmployees] = useState([]); + const [taskLinkBoardId, setTaskLinkBoardId] = useState(""); + const [taskLinkTaskId, setTaskLinkTaskId] = useState(""); + const [taskLinkMode, setTaskLinkMode] = useState("existing"); + const [taskLinkNewTitle, setTaskLinkNewTitle] = useState(""); + const [taskLinkNewDescription, setTaskLinkNewDescription] = useState(""); + const [taskLinkDetailsOpen, setTaskLinkDetailsOpen] = useState(false); + const [taskLinkColumnId, setTaskLinkColumnId] = useState(""); + const [taskLinkAssigneeId, setTaskLinkAssigneeId] = useState(""); + const [taskLinkPriority, setTaskLinkPriority] = useState("medium"); + const [taskLinkDueDate, setTaskLinkDueDate] = useState(""); + const [taskLinkLabelIds, setTaskLinkLabelIds] = useState([]); + const [taskLinkLoading, setTaskLinkLoading] = useState(false); + const [taskLinkError, setTaskLinkError] = useState(null); const linkedMessageId = Number(searchParams.get("message") || ""); + useEffect(() => { + displayMessageIdsRef.current = new Set(displayMessages.map((message) => message.id)); + }, [displayMessages]); + // Sync settings from chat useEffect(() => { if (cm.chat) { @@ -311,6 +365,31 @@ function MessageDialogPageContent() { return () => { wsManager.clearActiveChat(chatId); }; }, [chatId]); + useEffect(() => { + if (!chatId || Number.isNaN(chatId)) return undefined; + + const unsubscribe = wsManager.subscribe((event) => { + if (event.type !== "task_board_update") return; + const payload = event.data as { model?: string; object_id?: number } | undefined; + if (payload?.model !== "message") return; + const messageId = Number(payload.object_id); + if (!Number.isFinite(messageId) || !displayMessageIdsRef.current.has(messageId)) return; + + void apiClient.getChatMessagesAround(chatId, { + limit: 1, + around_id: messageId, + }).then((response) => { + const updated = response.messages?.find((message: Message) => message.id === messageId); + if (!updated) return; + cm.setMessages(prev => prev.map((message) => ( + message.id === messageId ? { ...message, ...updated } : message + ))); + }).catch(() => {}); + }); + + return unsubscribe; + }, [chatId, cm]); + useEffect(() => { if (isConnected) resetReloadGuard(); }, [isConnected, resetReloadGuard]); // WS event handler @@ -729,6 +808,181 @@ function MessageDialogPageContent() { }); }, []); + const selectedTaskLinkBoard = useMemo( + () => taskLinkBoards.find((item) => item.id === taskLinkBoardId) || null, + [taskLinkBoardId, taskLinkBoards], + ); + const selectedTaskLinkTask = useMemo( + () => (selectedTaskLinkBoard?.tasks || []).find((task) => task.id === taskLinkTaskId) || null, + [selectedTaskLinkBoard?.tasks, taskLinkTaskId], + ); + const selectedTaskLinkColumn = useMemo( + () => ( + (selectedTaskLinkBoard?.columns || []).find((column) => column.id === taskLinkColumnId) || + (selectedTaskLinkBoard?.columns || []).find((column) => !column.is_archived) || + null + ), + [selectedTaskLinkBoard?.columns, taskLinkColumnId], + ); + + const handleOpenTaskLinkModal = useCallback(async (message: Message) => { + setTaskLinkMessage(message); + setExpandedReplyActionForId(null); + setTaskLinkError(null); + setTaskLinkNewTitle(getDefaultTaskTitleFromMessage(message)); + setTaskLinkNewDescription(message.content || ""); + setTaskLinkLoading(true); + try { + const [response, employeesResponse] = await Promise.all([ + apiClient.getTaskBoards(), + apiClient.getEmployees({ limit: 200, is_active: true, ordering: "last_name" }), + ]); + const boards = (response.results || response || []) as TaskBoard[]; + const employees = (employeesResponse.results || employeesResponse || []) as User[]; + setTaskLinkBoards(boards); + setTaskLinkEmployees(employees); + const firstBoardWithTasks = boards.find((item) => (item.tasks || []).length > 0) || boards[0] || null; + const firstColumn = firstBoardWithTasks?.columns?.find((column) => !column.is_archived) || null; + setTaskLinkBoardId(firstBoardWithTasks?.id || ""); + setTaskLinkTaskId(firstBoardWithTasks?.tasks?.[0]?.id || ""); + setTaskLinkColumnId(firstColumn?.id || ""); + setTaskLinkMode(firstBoardWithTasks?.tasks?.length ? "existing" : "create"); + setTaskLinkDetailsOpen(false); + setTaskLinkAssigneeId(""); + setTaskLinkPriority("medium"); + setTaskLinkDueDate(""); + setTaskLinkLabelIds([]); + } catch (error) { + const messageText = error instanceof Error ? error.message : "Не удалось загрузить задачи"; + setTaskLinkError(messageText); + } finally { + setTaskLinkLoading(false); + } + }, []); + + const handleTaskLinkBoardChange = useCallback((boardId: number | "") => { + setTaskLinkBoardId(boardId); + const nextBoard = taskLinkBoards.find((item) => item.id === boardId) || null; + setTaskLinkTaskId(nextBoard?.tasks?.[0]?.id || ""); + setTaskLinkColumnId(nextBoard?.columns?.find((column) => !column.is_archived)?.id || ""); + setTaskLinkLabelIds([]); + if (!nextBoard?.tasks?.length) { + setTaskLinkMode("create"); + } + }, [taskLinkBoards]); + + const handleCloseTaskLinkModal = useCallback(() => { + if (taskLinkLoading) return; + setTaskLinkMessage(null); + setTaskLinkError(null); + setTaskLinkBoardId(""); + setTaskLinkTaskId(""); + setTaskLinkMode("existing"); + setTaskLinkNewTitle(""); + setTaskLinkNewDescription(""); + setTaskLinkDetailsOpen(false); + setTaskLinkColumnId(""); + setTaskLinkAssigneeId(""); + setTaskLinkPriority("medium"); + setTaskLinkDueDate(""); + setTaskLinkLabelIds([]); + }, [taskLinkLoading]); + + const toggleTaskLinkLabel = useCallback((labelId: number) => { + setTaskLinkLabelIds((current) => ( + current.includes(labelId) + ? current.filter((id) => id !== labelId) + : [...current, labelId] + )); + }, []); + + const handleSaveTaskLink = useCallback(async () => { + if (!taskLinkMessage || !selectedTaskLinkBoard) return; + if (taskLinkMode === "existing" && !selectedTaskLinkTask) return; + if (taskLinkMode === "create" && !taskLinkNewTitle.trim()) return; + + setTaskLinkLoading(true); + setTaskLinkError(null); + try { + let taskToLink = selectedTaskLinkTask; + if (taskLinkMode === "create") { + if (!selectedTaskLinkColumn) { + setTaskLinkError("На выбранной доске нет колонки для новой задачи"); + return; + } + taskToLink = await apiClient.createTask({ + board: selectedTaskLinkBoard.id, + column: selectedTaskLinkColumn.id, + title: taskLinkNewTitle.trim(), + description: taskLinkNewDescription.trim(), + assignee_id: taskLinkAssigneeId || null, + priority: taskLinkPriority, + due_date: taskLinkDueDate || null, + label_ids: taskLinkLabelIds, + }); + } + + if (!taskToLink) return; + + const link = await apiClient.linkTaskMessage(taskToLink.id, taskLinkMessage.id); + const taskColumn = selectedTaskLinkBoard.columns.find((column) => column.id === taskToLink.column) || null; + cm.setMessages(prev => prev.map((message) => { + if (message.id !== taskLinkMessage.id) return message; + const existing = message.linked_tasks || []; + if (existing.some((task) => task.id === taskToLink.id)) return message; + return { + ...message, + linked_tasks: [ + ...existing, + { + link_id: link.id, + id: taskToLink.id, + title: taskToLink.title, + board_id: selectedTaskLinkBoard.id, + board_name: selectedTaskLinkBoard.name, + column_id: taskToLink.column, + column_name: taskColumn?.name || taskToLink.column_name || "", + column_color: taskColumn?.color || "", + priority: taskToLink.priority, + priority_display: taskToLink.priority_display, + }, + ], + }; + })); + setTaskLinkMessage(null); + setTaskLinkError(null); + setTaskLinkBoardId(""); + setTaskLinkTaskId(""); + setTaskLinkMode("existing"); + setTaskLinkNewTitle(""); + setTaskLinkNewDescription(""); + setTaskLinkDetailsOpen(false); + setTaskLinkColumnId(""); + setTaskLinkAssigneeId(""); + setTaskLinkPriority("medium"); + setTaskLinkDueDate(""); + setTaskLinkLabelIds([]); + } catch (error) { + const messageText = error instanceof Error ? error.message : "Не удалось связать сообщение с задачей"; + setTaskLinkError(messageText); + } finally { + setTaskLinkLoading(false); + } + }, [ + cm, + selectedTaskLinkColumn, + selectedTaskLinkBoard, + selectedTaskLinkTask, + taskLinkAssigneeId, + taskLinkDueDate, + taskLinkLabelIds, + taskLinkMode, + taskLinkMessage, + taskLinkNewDescription, + taskLinkNewTitle, + taskLinkPriority, + ]); + const handleAttachmentLoad = useCallback((id: number) => { setBrokenMedia(prev => ({ ...prev, [id]: false })); messagesViewportRef.current?.updateScrollPosition(); @@ -1208,6 +1462,7 @@ function MessageDialogPageContent() { onQuickReact={handleReact} onOpenReactionPicker={(msg) => setReactionPickerForMessageId(msg.id)} onShowAllReaders={() => setIsReadersModalOpen(true)} + onLinkToTask={handleOpenTaskLinkModal} onRetry={handleRetryMessage} isRetrying={Boolean(message.local_id && retryingLocalIds.has(message.local_id))} retryDisabled={!canRetryMessage(message)} @@ -1305,6 +1560,261 @@ function MessageDialogPageContent() { setIsReadersModalOpen(false)} readers={selectedActionReaders} /> ) : null} + + + + + )} + > +
+ {taskLinkMessage ? ( +
+

Сообщение

+

+ {taskLinkMessage.content || "Сообщение без текста"} +

+
+ ) : null} + + + +
+ + +
+ + {taskLinkMode === "existing" ? ( + + ) : ( +
+ +