diff --git a/backend/Dockerfile b/backend/Dockerfile index 2236917f..914bd43f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,7 +4,7 @@ FROM python:3.13-slim WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 -RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev curl && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev curl ffmpeg && rm -rf /var/lib/apt/lists/* COPY backend/requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index 9ddf0304..66645a2b 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -339,6 +339,7 @@ class ProcurementItemAttachmentSerializer(serializers.ModelSerializer): read_only=True, ) download_url = serializers.SerializerMethodField() + thumbnail_url = serializers.SerializerMethodField() class Meta: model = ProcurementItemAttachment @@ -351,6 +352,7 @@ class ProcurementItemAttachmentSerializer(serializers.ModelSerializer): "uploaded_by_name", "created_at", "download_url", + "thumbnail_url", ] read_only_fields = fields @@ -360,6 +362,16 @@ class ProcurementItemAttachmentSerializer(serializers.ModelSerializer): f"attachments/{obj.id}/" ) + def get_thumbnail_url(self, obj): + from procurement.media import procurement_attachment_media_kind + + if not procurement_attachment_media_kind(obj): + return None + return ( + f"/api/v1/procurement/items/{obj.item_id}/" + f"attachments/{obj.id}/thumbnail/" + ) + class ProcurementItemSerializer(serializers.ModelSerializer): """Сериализатор для позиций заявки.""" diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 81f21aac..05e6b057 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -40,6 +40,7 @@ from procurement.models import ( ProcurementSettings, Supplier, ) +from procurement.media import ensure_procurement_attachment_thumbnail from procurement.activity import create_procurement_activity from procurement.notifications.handlers import ( notify_executor_reassigned, @@ -1517,6 +1518,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): "cancel_issue", "attachments", "attachment_detail", + "attachment_thumbnail", ]: permission_classes = [permissions.IsAuthenticated] else: @@ -1631,6 +1633,45 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): content_type=attachment.mime_type or "application/octet-stream", ) + @action( + detail=True, + methods=["get"], + url_path=r"attachments/(?P\d+)/thumbnail", + url_name="attachment-thumbnail", + ) + def attachment_thumbnail(self, request, pk=None, attachment_id=None): + item = self.get_object() + self._check_item_read_permission(item) + try: + attachment = item.attachments.get(pk=attachment_id) + except ProcurementItemAttachment.DoesNotExist as exc: + raise Http404 from exc + + if not attachment.thumbnail and not ensure_procurement_attachment_thumbnail( + attachment + ): + return Response( + {"detail": "Для файла не удалось создать миниатюру."}, + status=status.HTTP_404_NOT_FOUND, + ) + try: + file_handle = attachment.thumbnail.open("rb") + except (FileNotFoundError, OSError, ValueError): + return Response( + {"detail": "Миниатюра не найдена в хранилище."}, + status=status.HTTP_404_NOT_FOUND, + ) + + response = FileResponse( + file_handle, + as_attachment=False, + filename=f"procurement-attachment-{attachment.id}.webp", + content_type="image/webp", + ) + response["Cache-Control"] = "private, max-age=31536000, immutable" + response["X-Content-Type-Options"] = "nosniff" + return response + def _can_use_item_quick_action(self, user, procurement_request): return ( procurement_request.requestor_id == user.id diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 5a1e4f3d..77c4b648 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -1,6 +1,6 @@ from urllib.parse import urlsplit -from django.db.models import Count, Max +from django.db.models import Count, Max, Q from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse @@ -394,6 +394,7 @@ class TaskColumnRowSerializer(serializers.ModelSerializer): attrs["name"] = name return attrs + class TaskSerializer(serializers.ModelSerializer): created_by = EmployeeBriefSerializer(read_only=True) assignee = EmployeeBriefSerializer(read_only=True) @@ -527,83 +528,91 @@ class TaskSerializer(serializers.ModelSerializer): @extend_schema_field(serializers.IntegerField()) def get_linked_posts_count(self, obj): - if hasattr(obj, "linked_posts_count"): - return obj.linked_posts_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.POST, - ).count() + return self._get_linked_kind_count( + obj, + "linked_posts_count", + TaskLinkedObjectKind.POST, + ) @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() + return self._get_linked_kind_count( + obj, + "linked_messages_count", + TaskLinkedObjectKind.MESSAGE, + ) @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() + return self._get_linked_kind_count( + obj, + "linked_events_count", + TaskLinkedObjectKind.CALENDAR_EVENT, + ) @extend_schema_field(serializers.IntegerField()) def get_linked_documents_count(self, obj): - if hasattr(obj, "linked_documents_count"): - return obj.linked_documents_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.DOCUMENT, - ).count() + return self._get_linked_kind_count( + obj, + "linked_documents_count", + TaskLinkedObjectKind.DOCUMENT, + ) @extend_schema_field(serializers.IntegerField()) def get_linked_requests_count(self, obj): - if hasattr(obj, "linked_requests_count"): - return obj.linked_requests_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.REQUEST, - ).count() + return self._get_linked_kind_count( + obj, + "linked_requests_count", + TaskLinkedObjectKind.REQUEST, + ) @extend_schema_field(serializers.IntegerField()) def get_linked_procurement_requests_count(self, obj): - if hasattr(obj, "linked_procurement_requests_count"): - return obj.linked_procurement_requests_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST, - ).count() + return self._get_linked_kind_count( + obj, + "linked_procurement_requests_count", + TaskLinkedObjectKind.PROCUREMENT_REQUEST, + ) @extend_schema_field(serializers.IntegerField()) def get_linked_employees_count(self, obj): - if hasattr(obj, "linked_employees_count"): - return obj.linked_employees_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.EMPLOYEE, - ).count() + return self._get_linked_kind_count( + obj, + "linked_employees_count", + TaskLinkedObjectKind.EMPLOYEE, + ) @extend_schema_field(serializers.IntegerField()) def get_linked_guests_count(self, obj): - if hasattr(obj, "linked_guests_count"): - return obj.linked_guests_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.GUEST, - ).count() + return self._get_linked_kind_count( + obj, + "linked_guests_count", + TaskLinkedObjectKind.GUEST, + ) @extend_schema_field(serializers.IntegerField()) def get_linked_guest_visits_count(self, obj): - if hasattr(obj, "linked_guest_visits_count"): - return obj.linked_guest_visits_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.GUEST_VISIT, - ).count() + return self._get_linked_kind_count( + obj, + "linked_guest_visits_count", + TaskLinkedObjectKind.GUEST_VISIT, + ) @extend_schema_field(serializers.IntegerField()) def get_linked_attendance_records_count(self, obj): - if hasattr(obj, "linked_attendance_records_count"): - return obj.linked_attendance_records_count - return obj.linked_objects.filter( - kind=TaskLinkedObjectKind.ATTENDANCE_RECORD, - ).count() + return self._get_linked_kind_count( + obj, + "linked_attendance_records_count", + TaskLinkedObjectKind.ATTENDANCE_RECORD, + ) + + def _get_linked_kind_count(self, obj, attribute_name, kind): + if hasattr(obj, attribute_name): + return getattr(obj, attribute_name) + prefetched = getattr(obj, "_prefetched_objects_cache", {}).get("linked_objects") + if prefetched is not None: + return sum(link.kind == kind for link in prefetched) + return obj.linked_objects.filter(kind=kind).count() @extend_schema_field(serializers.IntegerField()) def get_linked_objects_count(self, obj): @@ -676,9 +685,7 @@ class TaskSerializer(serializers.ModelSerializer): board = attrs.get("board") or getattr(self.instance, "board", None) column = attrs.get("column") or getattr(self.instance, "column", None) row = ( - attrs.get("row") - if "row" in attrs - else getattr(self.instance, "row", None) + attrs.get("row") if "row" in attrs else getattr(self.instance, "row", None) ) participants = attrs.get("participants") @@ -800,12 +807,8 @@ class TaskBoardSerializer(serializers.ModelSerializer): labels = serializers.SerializerMethodField() tasks_count = serializers.IntegerField(read_only=True, default=0) is_default_for_current_user = serializers.SerializerMethodField() - group_id = serializers.IntegerField( - source="current_user_group_id", - read_only=True, - allow_null=True, - default=None, - ) + group_ids = serializers.SerializerMethodField() + group_id = serializers.SerializerMethodField() is_pinned = serializers.BooleanField( source="current_user_is_pinned", read_only=True, @@ -838,6 +841,7 @@ class TaskBoardSerializer(serializers.ModelSerializer): "labels", "tasks_count", "is_default_for_current_user", + "group_ids", "group_id", "is_pinned", "notifications_enabled", @@ -854,6 +858,27 @@ class TaskBoardSerializer(serializers.ModelSerializer): self._global_labels_cache = labels return TaskLabelSerializer(labels, many=True).data + @extend_schema_field(serializers.ListField(child=serializers.IntegerField())) + def get_group_ids(self, obj): + groups = getattr(obj, "current_user_groups", None) + if groups is None: + request = self.context.get("request") + user = getattr(request, "user", None) + if not user or not user.is_authenticated: + return [] + groups = TaskBoardGroup.objects.filter( + Q(user=user) | Q(is_shared=True), + boards=obj, + ).exclude( + hidden_by=user, + ) + return sorted(group.id for group in groups) + + @extend_schema_field(serializers.IntegerField(allow_null=True)) + def get_group_id(self, obj): + group_ids = self.get_group_ids(obj) + return group_ids[0] if group_ids else None + def validate(self, attrs): attrs = super().validate(attrs) access_scope = attrs.get("access_scope") @@ -926,11 +951,13 @@ class TaskBoardSerializer(serializers.ModelSerializer): @extend_schema_field(TaskColumnRowSerializer(many=True)) def get_column_rows(self, obj): - rows = TaskColumnRow.objects.filter( - column__board=obj, - ).annotate( - tasks_count=Count("tasks", distinct=True) - ).order_by("column_id", "position", "id") + rows = ( + TaskColumnRow.objects.filter( + column__board=obj, + ) + .annotate(tasks_count=Count("tasks", distinct=True)) + .order_by("column_id", "position", "id") + ) return TaskColumnRowSerializer(rows, many=True).data @extend_schema_field(serializers.BooleanField()) @@ -961,6 +988,7 @@ class TaskBoardSummarySerializer(TaskBoardSerializer): "open_tasks_count", "completed_tasks_count", "is_default_for_current_user", + "group_ids", "group_id", "is_pinned", "notifications_enabled", @@ -972,6 +1000,8 @@ class TaskBoardSummarySerializer(TaskBoardSerializer): class TaskBoardGroupSerializer(serializers.ModelSerializer): boards_count = serializers.IntegerField(read_only=True, default=0) is_default_for_current_user = serializers.SerializerMethodField() + is_hidden = serializers.SerializerMethodField() + is_owner = serializers.SerializerMethodField() class Meta: model = TaskBoardGroup @@ -979,8 +1009,11 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer): "id", "name", "position", + "is_shared", "boards_count", "is_default_for_current_user", + "is_hidden", + "is_owner", "created_at", "updated_at", ] @@ -991,14 +1024,18 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer): if not name: raise serializers.ValidationError("Укажите название группы.") request = self.context.get("request") - queryset = TaskBoardGroup.objects.filter( - user=getattr(request, "user", None), - name__iexact=name, + owner_id = ( + self.instance.user_id + if self.instance + else getattr(getattr(request, "user", None), "id", None) ) + queryset = TaskBoardGroup.objects.filter(user_id=owner_id, name__iexact=name) if self.instance: queryset = queryset.exclude(id=self.instance.id) if queryset.exists(): - raise serializers.ValidationError("Группа с таким названием уже существует.") + raise serializers.ValidationError( + "Группа с таким названием уже существует." + ) return name @extend_schema_field(serializers.BooleanField()) @@ -1015,6 +1052,25 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer): ) return self._default_group_id == obj.id + @extend_schema_field(serializers.BooleanField()) + def get_is_hidden(self, obj): + hidden_by = getattr(obj, "current_user_hidden_by", None) + if hidden_by is not None: + return bool(hidden_by) + request = self.context.get("request") + user = getattr(request, "user", None) + return bool( + user + and getattr(user, "is_authenticated", False) + and obj.hidden_by.filter(id=user.id).exists() + ) + + @extend_schema_field(serializers.BooleanField()) + def get_is_owner(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + return bool(user and obj.user_id == getattr(user, "id", None)) + class TaskLinkedPostSerializer(serializers.ModelSerializer): created_by = EmployeeBriefSerializer(read_only=True) @@ -1165,6 +1221,7 @@ class TaskActivitySerializer(serializers.ModelSerializer): class TaskAttachmentSerializer(serializers.ModelSerializer): uploaded_by = EmployeeBriefSerializer(read_only=True) download_url = serializers.SerializerMethodField() + thumbnail_url = serializers.SerializerMethodField() class Meta: model = TaskAttachment @@ -1178,6 +1235,7 @@ class TaskAttachmentSerializer(serializers.ModelSerializer): "uploaded_by", "created_at", "download_url", + "thumbnail_url", ] read_only_fields = fields @@ -1190,6 +1248,19 @@ class TaskAttachmentSerializer(serializers.ModelSerializer): request = self.context.get("request") return request.build_absolute_uri(url) if request else url + @extend_schema_field(serializers.URLField(allow_null=True)) + def get_thumbnail_url(self, obj): + from tasks.media import task_attachment_media_kind + + if not task_attachment_media_kind(obj): + return None + url = reverse( + "api:v1:tasks:task-attachment-thumbnail", + kwargs={"pk": obj.task_id, "attachment_id": obj.id}, + ) + request = self.context.get("request") + return request.build_absolute_uri(url) if request else url + class TaskChecklistItemSerializer(serializers.ModelSerializer): created_by = EmployeeBriefSerializer(read_only=True) @@ -1283,11 +1354,7 @@ class TaskLinkedCalendarEventSerializer(serializers.ModelSerializer): 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 - ): + if obj.kind != TaskLinkedObjectKind.CALENDAR_EVENT or event is None or not user: return False return user_can_access_calendar_event(user, event) @@ -1341,9 +1408,7 @@ class TaskLinkedDocumentSerializer(serializers.ModelSerializer): "is_regulation": document.is_regulation, "folder_path": document.folder_path, "file_name": ( - getattr(document.file, "name", None) - if document.file - else None + getattr(document.file, "name", None) if document.file else None ), } @@ -1354,11 +1419,7 @@ class TaskLinkedDocumentSerializer(serializers.ModelSerializer): document = getattr(obj, "content_object", None) request = self.context.get("request") user = getattr(request, "user", None) - if ( - obj.kind != TaskLinkedObjectKind.DOCUMENT - or document is None - or not user - ): + if obj.kind != TaskLinkedObjectKind.DOCUMENT or document is None or not user: return False return user_can_access_document(user, document) @@ -1425,11 +1486,7 @@ class TaskLinkedRequestSerializer(serializers.ModelSerializer): request_obj = getattr(obj, "content_object", None) request = self.context.get("request") user = getattr(request, "user", None) - if ( - obj.kind != TaskLinkedObjectKind.REQUEST - or request_obj is None - or not user - ): + if obj.kind != TaskLinkedObjectKind.REQUEST or request_obj is None or not user: return False return user_can_access_employee_request(user, request_obj) @@ -1448,7 +1505,9 @@ def get_request_content_type(): class TaskLinkedProcurementRequestSerializer(serializers.ModelSerializer): created_by = EmployeeBriefSerializer(read_only=True) procurement_request = serializers.SerializerMethodField() - procurement_request_id = serializers.IntegerField(source="object_id", read_only=True) + procurement_request_id = serializers.IntegerField( + source="object_id", read_only=True + ) can_open = serializers.SerializerMethodField() object_url = serializers.SerializerMethodField() @@ -1880,11 +1939,7 @@ def _linked_object_cover_payload(link, context): image_url = build_media_url(source.image, context.get("request")) elif kind == TaskLinkedObjectKind.MESSAGE: - title = ( - source.author.get_full_name() - or source.author.email - or "Сообщение" - ) + title = source.author.get_full_name() or source.author.email or "Сообщение" description = source.content or "" elif kind == TaskLinkedObjectKind.CALENDAR_EVENT: title = getattr(source, "title", None) or "Календарное событие" diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 1e635764..eb6b0c50 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -37,6 +37,7 @@ from rest_framework.response import Response from schedule.models import Event from tasks.access import ( + task_board_group_access_q, task_board_access_q, user_can_access_calendar_event, user_can_access_document, @@ -51,6 +52,7 @@ from tasks.access import ( from tasks.automation.engine import conditions_match, execute_automation from tasks.automation.schema import automation_catalog from tasks.comment_resources import add_comment_links_to_task +from tasks.media import ensure_task_attachment_thumbnail from tasks.models import ( Task, TaskActivity, @@ -66,6 +68,7 @@ from tasks.models import ( TaskBoardPlacement, TaskColumn, TaskColumnRow, + TaskChecklistItem, TaskCover, TaskCoverType, TaskExternalLink, @@ -159,23 +162,27 @@ def create_task_activity( def normalize_column_positions(column, row=None): - task_ids = list( - column.tasks.filter(row=row).order_by("position", "-created_at", "id").values_list( - "id", - flat=True, - ) + tasks = list( + column.tasks.filter(row=row) + .order_by("position", "-created_at", "id") + .only("id", "position") ) - for index, task_id in enumerate(task_ids): - Task.objects.filter(id=task_id).update(position=(index + 1) * 1000) + changed_tasks = [] + for index, task in enumerate(tasks): + position = (index + 1) * 1000 + if task.position == position: + continue + task.position = position + changed_tasks.append(task) + if changed_tasks: + Task.objects.bulk_update(changed_tasks, ["position"], batch_size=500) def send_task_checklist_update(task, event, item_id, *, cover_cleared=False): extra = { "task_id": task.id, "checklist_total": task.checklist_items.count(), - "checklist_completed": task.checklist_items.filter( - is_completed=True - ).count(), + "checklist_completed": task.checklist_items.filter(is_completed=True).count(), } if cover_cleared: extra["cover"] = None @@ -233,8 +240,9 @@ class TaskAutomationViewSet(viewsets.ModelViewSet): def _accessible_task(self, request, task_id): return get_object_or_404( - Task.objects.select_related("board", "column", "created_by", "assignee") - .filter( + Task.objects.select_related( + "board", "column", "created_by", "assignee" + ).filter( board__in=TaskBoard.objects.filter(task_board_access_q(request.user)) ), id=task_id, @@ -249,11 +257,20 @@ class TaskAutomationViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) task = self._accessible_task(request, task_id) - queryset = self.get_queryset().filter( - kind=TaskAutomationKind.BUTTON, - is_active=True, - ).filter(Q(applies_to_all_boards=True) | Q(boards=task.board_id)).distinct() - eligible = [automation for automation in queryset if conditions_match(task, automation.conditions)] + queryset = ( + self.get_queryset() + .filter( + kind=TaskAutomationKind.BUTTON, + is_active=True, + ) + .filter(Q(applies_to_all_boards=True) | Q(boards=task.board_id)) + .distinct() + ) + eligible = [ + automation + for automation in queryset + if conditions_match(task, automation.conditions) + ] return Response(self.get_serializer(eligible, many=True).data) @action(detail=True, methods=["post"]) @@ -303,10 +320,7 @@ class TaskBoardViewSet(viewsets.ModelViewSet): serializer_class = TaskBoardSerializer def get_serializer_class(self): - if ( - self.action == "list" - and self.request.query_params.get("view") == "summary" - ): + if self.action == "list" and self.request.query_params.get("view") == "summary": return TaskBoardSummarySerializer return super().get_serializer_class() @@ -319,12 +333,29 @@ class TaskBoardViewSet(viewsets.ModelViewSet): TaskBoard.objects.filter(is_archived=False) .filter(task_board_access_q(self.request.user)) .select_related("created_by") - .prefetch_related("members", "departments") - .annotate( - current_user_group_id=Subquery( - current_placement.values("group_id")[:1], - output_field=IntegerField(), + .prefetch_related( + "members", + "departments", + Prefetch( + "user_placements", + queryset=TaskBoardPlacement.objects.filter( + user=self.request.user, + ), + to_attr="current_user_placements", ), + Prefetch( + "board_groups", + queryset=( + TaskBoardGroup.objects.filter( + Q(user=self.request.user) | Q(is_shared=True), + ) + .exclude(hidden_by=self.request.user) + .distinct() + ), + to_attr="current_user_groups", + ), + ) + .annotate( current_user_is_pinned=Coalesce( Subquery( current_placement.values("is_pinned")[:1], @@ -364,8 +395,7 @@ class TaskBoardViewSet(viewsets.ModelViewSet): ) ) if not ( - self.action == "list" - and self.request.query_params.get("view") == "summary" + self.action == "list" and self.request.query_params.get("view") == "summary" ): queryset = queryset.prefetch_related( Prefetch( @@ -395,19 +425,95 @@ class TaskBoardViewSet(viewsets.ModelViewSet): "cover__external_link__created_by", "cover__linked_object", "cover__linked_object__content_type", - ).prefetch_related( + ) + .prefetch_related( "participants", "labels", - "linked_objects", - "external_links", - "attachments", - "checklist_items", + Prefetch( + "linked_objects", + queryset=TaskLinkedObject.objects.only( + "id", + "task_id", + "kind", + ), + ), + Prefetch( + "external_links", + queryset=TaskExternalLink.objects.only( + "id", + "task_id", + ), + ), + Prefetch( + "checklist_items", + queryset=TaskChecklistItem.objects.filter( + task__cover__kind=TaskCoverType.CHECKLIST, + ).only( + "id", + "task_id", + "title", + "is_completed", + "position", + ), + ), Prefetch( "cover__comment__task_attachments", queryset=TaskAttachment.objects.select_related( "uploaded_by" ), ), + ) + .annotate( + comments_count=Coalesce( + Subquery( + Message.objects.filter( + chat__type="comments", + chat__context_content_type=( + ContentType.objects.get_for_model(Task) + ), + chat__context_object_id=OuterRef("pk"), + is_deleted=False, + ) + .values("chat") + .annotate(count=Count("id")) + .values("count"), + output_field=IntegerField(), + ), + Value(0), + ), + attachments_count=Coalesce( + Subquery( + TaskAttachment.objects.filter(task_id=OuterRef("pk")) + .values("task_id") + .annotate(count=Count("id")) + .values("count"), + output_field=IntegerField(), + ), + Value(0), + ), + checklist_total=Coalesce( + Subquery( + TaskChecklistItem.objects.filter(task_id=OuterRef("pk")) + .values("task_id") + .annotate(count=Count("id")) + .values("count"), + output_field=IntegerField(), + ), + Value(0), + ), + checklist_completed=Coalesce( + Subquery( + TaskChecklistItem.objects.filter( + task_id=OuterRef("pk"), + is_completed=True, + ) + .values("task_id") + .annotate(count=Count("id")) + .values("count"), + output_field=IntegerField(), + ), + Value(0), + ), ), ), ) @@ -523,21 +629,7 @@ class TaskBoardViewSet(viewsets.ModelViewSet): board = self.get_object() group_id = request.data.get("group") if group_id in (None, ""): - placement = TaskBoardPlacement.objects.filter( - user=request.user, - board=board, - ).first() - if placement: - placement.group = None - if ( - not placement.is_pinned - and placement.notifications_enabled - and not placement.view_state - ): - placement.delete() - else: - placement.save(update_fields=["group", "updated_at"]) - return Response({"board": board.id, "group_id": None}) + return Response(self._set_board_groups(board, [])) try: group_id = int(group_id) @@ -547,16 +639,71 @@ class TaskBoardViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) group = get_object_or_404( - TaskBoardGroup, + TaskBoardGroup.objects.filter(task_board_group_access_q(request.user)), id=group_id, - user=request.user, ) - TaskBoardPlacement.objects.update_or_create( - user=request.user, - board=board, - defaults={"group": group}, + return Response(self._set_board_groups(board, [group])) + + @action(detail=True, methods=["post"], url_path="groups") + def set_groups(self, request, pk=None): + board = self.get_object() + raw_group_ids = request.data.get("groups", []) + if not isinstance(raw_group_ids, list): + return Response( + {"groups": ["Передайте список идентификаторов групп."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + group_ids = sorted({int(group_id) for group_id in raw_group_ids}) + except (TypeError, ValueError): + return Response( + {"groups": ["Идентификаторы групп должны быть числами."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + available_groups = ( + TaskBoardGroup.objects.filter(task_board_group_access_q(request.user)) + .exclude(hidden_by=request.user) + .distinct() ) - return Response({"board": board.id, "group_id": group.id}) + groups = list(available_groups.filter(id__in=group_ids).order_by("id")) + if len(groups) != len(group_ids): + return Response( + {"groups": ["Одна или несколько групп не найдены."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + return Response(self._set_board_groups(board, groups)) + + def _set_board_groups(self, board, groups): + with transaction.atomic(): + TaskBoard.objects.select_for_update().get(pk=board.pk) + editable_groups = ( + TaskBoardGroup.objects.filter( + task_board_group_access_q(self.request.user), + boards=board, + ) + .exclude(hidden_by=self.request.user) + .distinct() + ) + desired_ids = {group.id for group in groups} + removable_groups = list(editable_groups.exclude(id__in=desired_ids)) + if removable_groups: + board.board_groups.remove(*removable_groups) + if groups: + board.board_groups.add(*groups) + group_ids = sorted( + TaskBoardGroup.objects.filter( + Q(user=self.request.user) | Q(is_shared=True), + boards=board, + ) + .exclude(hidden_by=self.request.user) + .values_list("id", flat=True) + .distinct() + ) + return { + "board": board.id, + "group_ids": group_ids, + "group_id": group_ids[0] if group_ids else None, + } @action(detail=True, methods=["post"]) def pin(self, request, pk=None): @@ -564,14 +711,12 @@ class TaskBoardViewSet(viewsets.ModelViewSet): placement, _ = TaskBoardPlacement.objects.get_or_create( user=request.user, board=board, - defaults={"group": None}, ) placement.is_pinned = not placement.is_pinned placement.pinned_at = timezone.now() if placement.is_pinned else None is_pinned = placement.is_pinned if ( not placement.is_pinned - and placement.group_id is None and placement.notifications_enabled and not placement.view_state ): @@ -586,22 +731,18 @@ class TaskBoardViewSet(viewsets.ModelViewSet): placement, _ = TaskBoardPlacement.objects.get_or_create( user=request.user, board=board, - defaults={"group": None}, ) placement.notifications_enabled = not placement.notifications_enabled notifications_enabled = placement.notifications_enabled if ( placement.notifications_enabled - and placement.group_id is None and not placement.is_pinned and not placement.view_state ): placement.delete() else: placement.save(update_fields=["notifications_enabled", "updated_at"]) - return Response( - {"ok": True, "notifications_enabled": notifications_enabled} - ) + return Response({"ok": True, "notifications_enabled": notifications_enabled}) @action(detail=True, methods=["patch"], url_path="view-state") def view_state(self, request, pk=None): @@ -657,15 +798,14 @@ class TaskBoardViewSet(viewsets.ModelViewSet): placement, _ = TaskBoardPlacement.objects.select_for_update().get_or_create( user=request.user, board=board, - defaults={"group": None}, ) - state = placement.view_state.copy() if isinstance(placement.view_state, dict) else {} + state = ( + placement.view_state.copy() + if isinstance(placement.view_state, dict) + else {} + ) key = state_keys[scope] - values = { - value - for value in state.get(key, []) - if isinstance(value, int) - } + values = {value for value in state.get(key, []) if isinstance(value, int)} if collapsed: values.add(object_id) else: @@ -675,7 +815,6 @@ class TaskBoardViewSet(viewsets.ModelViewSet): placement.view_state = state if ( not state - and placement.group_id is None and not placement.is_pinned and placement.notifications_enabled ): @@ -686,9 +825,7 @@ class TaskBoardViewSet(viewsets.ModelViewSet): return Response( { "hidden_column_ids": state.get("hidden_column_ids", []), - "collapsed_base_column_ids": state.get( - "collapsed_base_column_ids", [] - ), + "collapsed_base_column_ids": state.get("collapsed_base_column_ids", []), "collapsed_row_ids": state.get("collapsed_row_ids", []), } ) @@ -714,19 +851,41 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet): serializer_class = TaskBoardGroupSerializer def get_queryset(self): + accessible_boards = TaskBoard.objects.filter( + task_board_access_q(self.request.user) + ) return ( - TaskBoardGroup.objects.filter(user=self.request.user) - .annotate(boards_count=Count("board_placements", distinct=True)) + TaskBoardGroup.objects.filter( + task_board_group_access_q(self.request.user) + ) + .prefetch_related( + Prefetch( + "hidden_by", + queryset=Employee.objects.filter(id=self.request.user.id), + to_attr="current_user_hidden_by", + ) + ) + .annotate( + boards_count=Count( + "boards", + filter=Q(boards__in=accessible_boards), + distinct=True, + ) + ) + .distinct() .order_by("position", "name", "id") ) def perform_create(self, serializer): - max_position = self.get_queryset().aggregate(value=Max("position"))["value"] or 0 + max_position = ( + self.get_queryset().aggregate(value=Max("position"))["value"] or 0 + ) serializer.save(user=self.request.user, position=max_position + 1000) @action(detail=True, methods=["post"], url_path="set-default") def set_default(self, request, pk=None): group = self.get_object() + group.hidden_by.remove(request.user) settings_obj, _ = TaskUserSettings.objects.get_or_create(user=request.user) is_default = settings_obj.default_group_id != group.id settings_obj.default_group = group if is_default else None @@ -739,16 +898,25 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet): } ) - def perform_destroy(self, instance): - user = self.request.user - instance.delete() - TaskBoardPlacement.objects.filter( - user=user, - group__isnull=True, - is_pinned=False, - notifications_enabled=True, - view_state={}, - ).delete() + @action(detail=True, methods=["post"], url_path="toggle-hidden") + def toggle_hidden(self, request, pk=None): + group = self.get_object() + is_hidden = group.hidden_by.filter(id=request.user.id).exists() + if is_hidden: + group.hidden_by.remove(request.user) + else: + group.hidden_by.add(request.user) + TaskUserSettings.objects.filter( + user=request.user, + default_group=group, + ).update(default_group=None) + group.current_user_hidden_by = [] if is_hidden else [request.user] + return Response( + { + "group": self.get_serializer(group).data, + "is_hidden": not is_hidden, + } + ) class TaskColumnViewSet(viewsets.ModelViewSet): @@ -841,11 +1009,7 @@ class TaskColumnViewSet(viewsets.ModelViewSet): ) if column.tasks.exists(): return Response( - { - "detail": ( - "Переместите задачи в другую колонку перед удалением." - ) - }, + {"detail": ("Переместите задачи в другую колонку перед удалением.")}, status=status.HTTP_400_BAD_REQUEST, ) if ( @@ -1043,9 +1207,7 @@ class TaskLabelViewSet(viewsets.ModelViewSet): send_task_board_update(board, "updated", "label", label.id) def perform_destroy(self, instance): - board_ids = list( - instance.tasks.values_list("board_id", flat=True).distinct() - ) + board_ids = list(instance.tasks.values_list("board_id", flat=True).distinct()) label_id = instance.id instance.delete() for board in TaskBoard.objects.filter(id__in=board_ids): @@ -1118,9 +1280,7 @@ class TaskViewSet(viewsets.ModelViewSet): ), linked_events_count=Count( "linked_objects", - filter=Q( - linked_objects__kind=TaskLinkedObjectKind.CALENDAR_EVENT - ), + filter=Q(linked_objects__kind=TaskLinkedObjectKind.CALENDAR_EVENT), distinct=True, ), linked_documents_count=Count( @@ -1140,9 +1300,7 @@ class TaskViewSet(viewsets.ModelViewSet): linked_procurement_requests_count=Count( "linked_objects", filter=Q( - linked_objects__kind=( - TaskLinkedObjectKind.PROCUREMENT_REQUEST - ), + linked_objects__kind=(TaskLinkedObjectKind.PROCUREMENT_REQUEST), ), distinct=True, ), @@ -1170,9 +1328,7 @@ class TaskViewSet(viewsets.ModelViewSet): linked_attendance_records_count=Count( "linked_objects", filter=Q( - linked_objects__kind=( - TaskLinkedObjectKind.ATTENDANCE_RECORD - ), + linked_objects__kind=(TaskLinkedObjectKind.ATTENDANCE_RECORD), ), distinct=True, ), @@ -1242,9 +1398,7 @@ class TaskViewSet(viewsets.ModelViewSet): "due_date": task.due_date.isoformat() if task.due_date else None, "row_id": task.row_id, "label_ids": sorted(task.labels.values_list("id", flat=True)), - "participant_ids": sorted( - task.participants.values_list("id", flat=True) - ), + "participant_ids": sorted(task.participants.values_list("id", flat=True)), } changed_fields = [ field for field, value in current.items() if previous[field] != value @@ -1273,11 +1427,12 @@ class TaskViewSet(viewsets.ModelViewSet): def _move_task(self, task, column, row, position, actor): old_column = task.column old_row = task.row + normalize_positions = position not in (None, "") if position in (None, ""): max_position = ( - Task.objects.filter(column=column, row=row).exclude(id=task.id).aggregate( - Max("position") - )["position__max"] + Task.objects.filter(column=column, row=row) + .exclude(id=task.id) + .aggregate(Max("position"))["position__max"] or 0 ) position = max_position + 1000 @@ -1289,9 +1444,14 @@ class TaskViewSet(viewsets.ModelViewSet): update_fields=["column", "row", "position", "completed_at", "updated_at"] ) - normalize_column_positions(column, row) - if old_column.id != column.id or old_row != row: - normalize_column_positions(old_column, old_row) + # При обычном переносе карточка добавляется в конец с разреженной + # позицией. Перенумерация обеих колонок здесь не нужна и на больших + # досках превращалась в сотни отдельных UPDATE. Нормализуем порядок + # только когда клиент явно передал позицию для сортировки внутри ячейки. + if normalize_positions: + normalize_column_positions(column, row) + if old_column.id != column.id or old_row != row: + normalize_column_positions(old_column, old_row) task.refresh_from_db() activity_metadata = { @@ -1322,7 +1482,11 @@ class TaskViewSet(viewsets.ModelViewSet): "moved", "task", task.id, - extra={"column_id": task.column_id, "row_id": task.row_id}, + extra={ + "column_id": task.column_id, + "row_id": task.row_id, + "position": task.position, + }, ) return task @@ -1569,8 +1733,7 @@ class TaskViewSet(viewsets.ModelViewSet): ) content_type = attachment.mime_type or ( - mimetypes.guess_type(attachment.file_name)[0] - or "application/octet-stream" + mimetypes.guess_type(attachment.file_name)[0] or "application/octet-stream" ) response = FileResponse( file_handle, @@ -1581,6 +1744,43 @@ class TaskViewSet(viewsets.ModelViewSet): response["X-Content-Type-Options"] = "nosniff" return response + @action( + detail=True, + methods=["get"], + url_path=r"attachments/(?P\d+)/thumbnail", + url_name="attachment-thumbnail", + ) + def attachment_thumbnail(self, request, pk=None, attachment_id=None): + task = self.get_object() + attachment = get_object_or_404( + task.attachments, + id=attachment_id, + ) + if not attachment.thumbnail and not ensure_task_attachment_thumbnail( + attachment + ): + return Response( + {"detail": "Для файла не удалось создать миниатюру."}, + status=status.HTTP_404_NOT_FOUND, + ) + try: + file_handle = attachment.thumbnail.open("rb") + except (FileNotFoundError, OSError, ValueError): + return Response( + {"detail": "Миниатюра не найдена в хранилище."}, + status=status.HTTP_404_NOT_FOUND, + ) + + response = FileResponse( + file_handle, + as_attachment=False, + filename=f"task-cover-{attachment.id}.webp", + content_type="image/webp", + ) + response["Cache-Control"] = "private, max-age=31536000, immutable" + response["X-Content-Type-Options"] = "nosniff" + return response + @action( detail=True, methods=["delete"], @@ -1655,10 +1855,7 @@ class TaskViewSet(viewsets.ModelViewSet): ) source_id = request.data.get("source_id") - if ( - kind != TaskCoverType.CHECKLIST - and not (attachment_id or source_id) - ): + if kind != TaskCoverType.CHECKLIST and not (attachment_id or source_id): return Response( {"source_id": ["Укажите источник обложки."]}, status=status.HTTP_400_BAD_REQUEST, @@ -1944,9 +2141,7 @@ class TaskViewSet(viewsets.ModelViewSet): serializer = TaskExternalLinkSerializer(link) return Response( serializer.data, - status=( - status.HTTP_201_CREATED if created else status.HTTP_200_OK - ), + status=(status.HTTP_201_CREATED if created else status.HTTP_200_OK), ) @action( @@ -3157,7 +3352,9 @@ class TaskViewSet(viewsets.ModelViewSet): ) return Response(status=status.HTTP_204_NO_CONTENT) - @action(detail=True, methods=["get", "post"], url_path="linked-procurement-requests") + @action( + detail=True, methods=["get", "post"], url_path="linked-procurement-requests" + ) def linked_procurement_requests(self, request, pk=None): task = self.get_object() if request.method == "GET": diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py index 6c566875..f5e9af99 100644 --- a/backend/notifications/api/views.py +++ b/backend/notifications/api/views.py @@ -106,6 +106,10 @@ def get_notifications(request): queryset = Notification.objects.filter( recipient=request.user, deleted=False, + ).select_related( + 'actor_content_type', + ).prefetch_related( + 'actor', ).order_by('-timestamp') # Фильтры @@ -129,7 +133,7 @@ def get_notifications(request): 'total': total, 'page': page, 'page_size': page_size, - 'unread_count': queryset.filter(unread=True).count(), + 'unread_count': total if unread_only else queryset.filter(unread=True).count(), 'notifications': [ { 'id': n.id, diff --git a/backend/procurement/media.py b/backend/procurement/media.py new file mode 100644 index 00000000..42806c65 --- /dev/null +++ b/backend/procurement/media.py @@ -0,0 +1,43 @@ +import logging + +from django.core.files.base import ContentFile + +from tasks.media import attachment_media_kind, render_attachment_thumbnail + +from .models import ProcurementItemAttachment + + +logger = logging.getLogger(__name__) + + +def procurement_attachment_media_kind( + attachment: ProcurementItemAttachment, +) -> str | None: + return attachment_media_kind(attachment) + + +def ensure_procurement_attachment_thumbnail( + attachment: ProcurementItemAttachment, +) -> bool: + if attachment.thumbnail: + return True + if not procurement_attachment_media_kind(attachment): + return False + + try: + thumbnail = render_attachment_thumbnail(attachment) + attachment.thumbnail.save( + ( + f"procurement-item-{attachment.item_id}-" + f"attachment-{attachment.id}.webp" + ), + ContentFile(thumbnail), + save=True, + ) + return True + except Exception: + logger.exception( + "Could not generate thumbnail for procurement attachment %s", + attachment.id, + ) + return False diff --git a/backend/procurement/migrations/0019_procurementitemattachment_thumbnail.py b/backend/procurement/migrations/0019_procurementitemattachment_thumbnail.py new file mode 100644 index 00000000..38a74518 --- /dev/null +++ b/backend/procurement/migrations/0019_procurementitemattachment_thumbnail.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("procurement", "0018_procurementitemattachment_comment"), + ] + + operations = [ + migrations.AddField( + model_name="procurementitemattachment", + name="thumbnail", + field=models.ImageField( + blank=True, + null=True, + upload_to="procurement/item_attachment_thumbnails/%Y/%m/%d/", + verbose_name="Миниатюра", + ), + ), + ] diff --git a/backend/procurement/models.py b/backend/procurement/models.py index 352a6ee7..42f15aa4 100644 --- a/backend/procurement/models.py +++ b/backend/procurement/models.py @@ -693,6 +693,12 @@ class ProcurementItemAttachment(models.Model): upload_to=procurement_item_attachment_upload_to, max_length=500, ) + thumbnail = models.ImageField( + "Миниатюра", + upload_to="procurement/item_attachment_thumbnails/%Y/%m/%d/", + null=True, + blank=True, + ) file_name = models.TextField("Имя файла") file_size = models.BigIntegerField("Размер файла", default=0) mime_type = models.CharField("MIME-тип", max_length=255, blank=True) diff --git a/backend/tasks/access.py b/backend/tasks/access.py index c1a181b9..50a76c8b 100644 --- a/backend/tasks/access.py +++ b/backend/tasks/access.py @@ -51,6 +51,15 @@ def task_board_access_q(user): return q +def task_board_group_access_q(user): + """Groups owned by the user or shared through at least one accessible board.""" + accessible_boards = TaskBoard.objects.filter(task_board_access_q(user)) + return Q(user=user) | Q( + is_shared=True, + boards__in=accessible_boards, + ) + + 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 diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index 1a2ce054..00c9a657 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -43,24 +43,24 @@ class TaskBoardAdmin(admin.ModelAdmin): @admin.register(TaskBoardGroup) class TaskBoardGroupAdmin(admin.ModelAdmin): - list_display = ["name", "user", "position", "created_at"] + list_display = ["name", "user", "is_shared", "position", "created_at"] + list_filter = ["is_shared"] search_fields = ["name", "user__email"] - autocomplete_fields = ["user"] + autocomplete_fields = ["user", "boards", "hidden_by"] @admin.register(TaskBoardPlacement) class TaskBoardPlacementAdmin(admin.ModelAdmin): list_display = [ "board", - "group", "user", "is_pinned", "notifications_enabled", "updated_at", ] - list_filter = ["is_pinned", "notifications_enabled", "group"] - search_fields = ["board__name", "group__name", "user__email"] - autocomplete_fields = ["board", "group", "user"] + list_filter = ["is_pinned", "notifications_enabled"] + search_fields = ["board__name", "user__email"] + autocomplete_fields = ["board", "user"] readonly_fields = ["view_state"] diff --git a/backend/tasks/media.py b/backend/tasks/media.py new file mode 100644 index 00000000..dbb12287 --- /dev/null +++ b/backend/tasks/media.py @@ -0,0 +1,172 @@ +import io +import logging +import shutil +import subprocess +import tempfile +from pathlib import Path + +from django.core.files.base import ContentFile +from PIL import Image, ImageOps + +from tasks.models import TaskAttachment + + +logger = logging.getLogger(__name__) + +TASK_COVER_THUMBNAIL_SIZE = (640, 280) +IMAGE_EXTENSIONS = { + ".avif", + ".bmp", + ".gif", + ".heic", + ".heif", + ".jpeg", + ".jpg", + ".png", + ".webp", +} +VIDEO_EXTENSIONS = { + ".m4v", + ".mkv", + ".mov", + ".mp4", + ".mpeg", + ".mpg", + ".ogv", + ".webm", +} + + +def attachment_media_kind(attachment) -> str | None: + mime_type = (attachment.mime_type or "").lower() + suffix = Path(attachment.file_name or "").suffix.lower() + if mime_type.startswith("image/") or suffix in IMAGE_EXTENSIONS: + return "image" + if mime_type.startswith("video/") or suffix in VIDEO_EXTENSIONS: + return "video" + return None + + +def task_attachment_media_kind(attachment: TaskAttachment) -> str | None: + return attachment_media_kind(attachment) + + +def _render_image_thumbnail(attachment: TaskAttachment) -> bytes: + with attachment.file.open("rb") as source: + image = Image.open(source) + image.seek(0) + image = ImageOps.exif_transpose(image) + if image.mode not in ("RGB", "RGBA"): + image = image.convert("RGBA" if "transparency" in image.info else "RGB") + if image.mode == "RGBA": + background = Image.new("RGB", image.size, (20, 27, 36)) + background.paste(image, mask=image.getchannel("A")) + image = background + image = ImageOps.fit( + image, + TASK_COVER_THUMBNAIL_SIZE, + method=Image.Resampling.LANCZOS, + ) + output = io.BytesIO() + image.save(output, format="WEBP", quality=84, method=5) + return output.getvalue() + + +def _render_video_thumbnail(attachment: TaskAttachment) -> bytes: + ffmpeg = shutil.which("ffmpeg") + if not ffmpeg: + raise RuntimeError("ffmpeg is not installed") + + input_suffix = Path(attachment.file_name or "").suffix or ".video" + input_path = "" + temporary_input = False + output_path = "" + try: + try: + input_path = attachment.file.path + except (AttributeError, NotImplementedError): + with tempfile.NamedTemporaryFile( + suffix=input_suffix, delete=False + ) as source_file: + input_path = source_file.name + temporary_input = True + with attachment.file.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + source_file.write(chunk) + with tempfile.NamedTemporaryFile(suffix=".webp", delete=False) as output_file: + output_path = output_file.name + + filter_value = "scale=640:280:force_original_aspect_ratio=increase,crop=640:280" + command = [ + ffmpeg, + "-hide_banner", + "-loglevel", + "error", + "-ss", + "0.5", + "-i", + input_path, + "-frames:v", + "1", + "-vf", + filter_value, + "-c:v", + "libwebp", + "-quality", + "84", + "-y", + output_path, + ] + result = subprocess.run( + command, + capture_output=True, + check=False, + timeout=30, + ) + if result.returncode != 0: + command[5] = "0" + result = subprocess.run( + command, + capture_output=True, + check=False, + timeout=30, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.decode("utf-8", errors="replace")) + return Path(output_path).read_bytes() + finally: + if temporary_input and input_path: + Path(input_path).unlink(missing_ok=True) + if output_path: + Path(output_path).unlink(missing_ok=True) + + +def render_attachment_thumbnail(attachment) -> bytes: + media_kind = attachment_media_kind(attachment) + if media_kind == "image": + return _render_image_thumbnail(attachment) + if media_kind == "video": + return _render_video_thumbnail(attachment) + raise ValueError("Attachment is not supported media") + + +def ensure_task_attachment_thumbnail(attachment: TaskAttachment) -> bool: + if attachment.thumbnail: + return True + if not task_attachment_media_kind(attachment): + return False + + try: + thumbnail = render_attachment_thumbnail(attachment) + attachment.thumbnail.save( + f"task-{attachment.task_id}-attachment-{attachment.id}.webp", + ContentFile(thumbnail), + save=True, + ) + return True + except Exception: + logger.exception( + "Could not generate thumbnail for task attachment %s", + attachment.id, + ) + return False diff --git a/backend/tasks/migrations/0035_taskattachment_thumbnail.py b/backend/tasks/migrations/0035_taskattachment_thumbnail.py new file mode 100644 index 00000000..4d573834 --- /dev/null +++ b/backend/tasks/migrations/0035_taskattachment_thumbnail.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0034_cleanup_empty_checklist_covers"), + ] + + operations = [ + migrations.AddField( + model_name="taskattachment", + name="thumbnail", + field=models.ImageField( + blank=True, + null=True, + upload_to="task_attachment_thumbnails/%Y/%m/%d/", + verbose_name="Миниатюра", + ), + ), + ] diff --git a/backend/tasks/migrations/0036_taskboardplacement_multiple_groups.py b/backend/tasks/migrations/0036_taskboardplacement_multiple_groups.py new file mode 100644 index 00000000..8a83d7a6 --- /dev/null +++ b/backend/tasks/migrations/0036_taskboardplacement_multiple_groups.py @@ -0,0 +1,48 @@ +from django.db import migrations, models + + +def copy_group_to_groups(apps, schema_editor): + placement_model = apps.get_model("tasks", "TaskBoardPlacement") + for placement in placement_model.objects.exclude(group_id=None).iterator(): + placement.groups.add(placement.group_id) + + +def copy_first_group_back(apps, schema_editor): + placement_model = apps.get_model("tasks", "TaskBoardPlacement") + for placement in placement_model.objects.iterator(): + placement.group_id = placement.groups.values_list("id", flat=True).first() + placement.save(update_fields=["group"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0035_taskattachment_thumbnail"), + ] + + operations = [ + migrations.AddField( + model_name="taskboardplacement", + name="groups", + field=models.ManyToManyField( + blank=True, + related_name="multi_board_placements", + to="tasks.taskboardgroup", + verbose_name="Группы", + ), + ), + migrations.RunPython(copy_group_to_groups, copy_first_group_back), + migrations.RemoveField( + model_name="taskboardplacement", + name="group", + ), + migrations.AlterField( + model_name="taskboardplacement", + name="groups", + field=models.ManyToManyField( + blank=True, + related_name="board_placements", + to="tasks.taskboardgroup", + verbose_name="Группы", + ), + ), + ] diff --git a/backend/tasks/migrations/0037_shared_task_board_groups.py b/backend/tasks/migrations/0037_shared_task_board_groups.py new file mode 100644 index 00000000..113b143e --- /dev/null +++ b/backend/tasks/migrations/0037_shared_task_board_groups.py @@ -0,0 +1,95 @@ +# Generated by Django 5.2.4 on 2026-08-07 14:41 + +from django.conf import settings +from django.db import migrations, models + + +def copy_personal_group_boards(apps, schema_editor): + placement_model = apps.get_model("tasks", "TaskBoardPlacement") + group_model = apps.get_model("tasks", "TaskBoardGroup") + placement_boards = dict( + placement_model.objects.values_list("id", "board_id") + ) + old_through = placement_model.groups.through + new_through = group_model.boards.through + links = [] + for placement_id, group_id in old_through.objects.values_list( + "taskboardplacement_id", + "taskboardgroup_id", + ).iterator(): + board_id = placement_boards.get(placement_id) + if board_id: + links.append( + new_through( + taskboardgroup_id=group_id, + taskboard_id=board_id, + ) + ) + new_through.objects.bulk_create(links, ignore_conflicts=True) + + +def restore_personal_group_placements(apps, schema_editor): + placement_model = apps.get_model("tasks", "TaskBoardPlacement") + group_model = apps.get_model("tasks", "TaskBoardGroup") + old_through = placement_model.groups.through + new_through = group_model.boards.through + group_owners = dict(group_model.objects.values_list("id", "user_id")) + for group_id, board_id in new_through.objects.values_list( + "taskboardgroup_id", + "taskboard_id", + ).iterator(): + owner_id = group_owners.get(group_id) + if not owner_id: + continue + placement, _ = placement_model.objects.get_or_create( + user_id=owner_id, + board_id=board_id, + ) + old_through.objects.get_or_create( + taskboardplacement_id=placement.id, + taskboardgroup_id=group_id, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0036_taskboardplacement_multiple_groups'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="taskboardgroup", + name="boards", + field=models.ManyToManyField( + blank=True, + related_name="board_groups", + to="tasks.taskboard", + verbose_name="Доски", + ), + ), + migrations.AddField( + model_name="taskboardgroup", + name="hidden_by", + field=models.ManyToManyField( + blank=True, + related_name="hidden_task_board_groups", + to=settings.AUTH_USER_MODEL, + verbose_name="Скрыта пользователями", + ), + ), + migrations.AddField( + model_name="taskboardgroup", + name="is_shared", + field=models.BooleanField(default=False, verbose_name="Общая"), + ), + migrations.RunPython( + copy_personal_group_boards, + restore_personal_group_placements, + ), + migrations.RemoveField( + model_name="taskboardplacement", + name="groups", + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index f020f33f..4794e005 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -114,6 +114,19 @@ class TaskBoardGroup(models.Model): related_name="task_board_groups", verbose_name="Пользователь", ) + boards = models.ManyToManyField( + TaskBoard, + blank=True, + related_name="board_groups", + verbose_name="Доски", + ) + is_shared = models.BooleanField("Общая", default=False) + hidden_by = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="hidden_task_board_groups", + verbose_name="Скрыта пользователями", + ) position = models.PositiveIntegerField("Позиция", default=0) created_at = models.DateTimeField("Создано", auto_now_add=True) updated_at = models.DateTimeField("Обновлено", auto_now=True) @@ -146,14 +159,6 @@ class TaskBoardPlacement(models.Model): related_name="user_placements", verbose_name="Доска", ) - group = models.ForeignKey( - TaskBoardGroup, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="board_placements", - verbose_name="Группа", - ) is_pinned = models.BooleanField("Закреплена", default=False) pinned_at = models.DateTimeField("Закреплена в", null=True, blank=True) notifications_enabled = models.BooleanField("Уведомления", default=True) @@ -180,7 +185,7 @@ class TaskBoardPlacement(models.Model): ] def __str__(self): - return f"{self.user}: {self.board} -> {self.group}" + return f"{self.user}: {self.board}" class TaskUserSettings(models.Model): @@ -551,6 +556,12 @@ class TaskAttachment(models.Model): "Файл", upload_to="task_attachments/%Y/%m/%d/", ) + thumbnail = models.ImageField( + "Миниатюра", + upload_to="task_attachment_thumbnails/%Y/%m/%d/", + null=True, + blank=True, + ) file_name = models.CharField("Название файла", max_length=255) file_size = models.PositiveBigIntegerField("Размер файла (байты)") mime_type = models.CharField("MIME-тип", max_length=255, blank=True) diff --git a/backend/tests/api/v1/notifications/test_notifications_api.py b/backend/tests/api/v1/notifications/test_notifications_api.py index 70eaec19..a01628b0 100644 --- a/backend/tests/api/v1/notifications/test_notifications_api.py +++ b/backend/tests/api/v1/notifications/test_notifications_api.py @@ -2,6 +2,9 @@ from unittest.mock import patch import pytest from django.core.cache import cache +from django.contrib.contenttypes.models import ContentType +from django.db import connection +from django.test.utils import CaptureQueriesContext from push_notifications.models import WebPushDevice from notifications.cache import unread_summary_cache_key @@ -16,6 +19,39 @@ from notifications.models import ( pytestmark = pytest.mark.django_db +class TestNotificationList: + def test_notification_actors_are_prefetched(self, api_client, user_factory): + recipient = user_factory() + actor = user_factory() + actor_content_type = ContentType.objects.get_for_model(actor) + Notification.objects.bulk_create([ + Notification( + recipient=recipient, + actor_content_type=actor_content_type, + actor_object_id=str(actor.id), + verb="task_moved", + description=f"Уведомление {index}", + ) + for index in range(50) + ]) + api_client.force_authenticate(user=recipient) + + with CaptureQueriesContext(connection) as queries: + response = api_client.get( + "/api/v1/notifications/", + {"page_size": 50, "unread_only": "true"}, + ) + + assert response.status_code == 200 + assert len(response.data["notifications"]) == 50 + assert response.data["unread_count"] == 50 + assert all( + item["actor"]["id"] == actor.id + for item in response.data["notifications"] + ) + assert len(queries) <= 5 + + class TestNotificationUnreadSummary: def test_unread_summary_groups_unread_notifications_by_verb( self, api_client, user_factory diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 3facc1b0..7b8d9d5f 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -2,6 +2,7 @@ Тесты API для заявок на закупку (ProcurementRequest). """ +import io from datetime import timedelta from decimal import Decimal from unittest.mock import patch @@ -14,6 +15,7 @@ from django.urls import reverse from django.utils.dateparse import parse_datetime from django.utils import timezone from push_notifications.models import WebPushDevice +from PIL import Image from rest_framework import status from rest_framework.test import APIClient @@ -1183,6 +1185,63 @@ class TestProcurementItemComments: class TestProcurementItemAttachments: + def test_image_attachment_has_lazy_thumbnail( + self, + api_client, + user, + procurement_item, + settings, + tmp_path, + ): + settings.MEDIA_ROOT = tmp_path + api_client.force_authenticate(user=user) + source = io.BytesIO() + Image.new("RGB", (1200, 800), (32, 142, 210)).save(source, "PNG") + source.seek(0) + + upload_response = api_client.post( + reverse( + "api:v1:procurement:procurementitem-attachments", + kwargs={"pk": procurement_item.id}, + ), + { + "files": SimpleUploadedFile( + "position-photo.png", + source.getvalue(), + content_type="image/png", + ), + }, + format="multipart", + ) + + assert upload_response.status_code == status.HTTP_201_CREATED + attachment_id = upload_response.data[0]["id"] + assert upload_response.data[0]["thumbnail_url"].endswith( + f"/{attachment_id}/thumbnail/" + ) + attachment = ProcurementItemAttachment.objects.get(pk=attachment_id) + assert not attachment.thumbnail + + thumbnail_response = api_client.get( + reverse( + "api:v1:procurement:procurementitem-attachment-thumbnail", + kwargs={ + "pk": procurement_item.id, + "attachment_id": attachment_id, + }, + ) + ) + + assert thumbnail_response.status_code == status.HTTP_200_OK + assert thumbnail_response["Content-Type"] == "image/webp" + thumbnail = Image.open( + io.BytesIO(b"".join(thumbnail_response.streaming_content)) + ) + assert thumbnail.format == "WEBP" + assert thumbnail.size == (640, 280) + attachment.refresh_from_db() + assert attachment.thumbnail.name.endswith(".webp") + def test_archive_attachment_lifecycle( self, api_client, @@ -1297,6 +1356,16 @@ class TestProcurementItemAttachments: ) assert response.status_code == status.HTTP_403_FORBIDDEN + thumbnail_response = api_client.get( + reverse( + "api:v1:procurement:procurementitem-attachment-thumbnail", + kwargs={ + "pk": procurement_item.id, + "attachment_id": attachment.id, + }, + ) + ) + assert thumbnail_response.status_code == status.HTTP_403_FORBIDDEN # ============================================================================== diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 9728677f..ddef64b4 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -1,12 +1,16 @@ import base64 +import io from datetime import date, timedelta from pathlib import Path from unittest.mock import patch import pytest +from PIL import Image from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.files.uploadedfile import SimpleUploadedFile +from django.db import connection +from django.test.utils import CaptureQueriesContext from django.urls import reverse from django.utils import timezone from rest_framework import status @@ -41,6 +45,7 @@ from tasks.models import ( TaskUserSettings, ) from tasks.realtime import get_task_board_recipient_ids +from api.v1.tasks.views import TaskViewSet pytestmark = pytest.mark.django_db @@ -98,11 +103,63 @@ def test_default_board_creates_work_columns(api_client, user): assert TaskColumn.objects.count() == 4 +def test_board_detail_query_count_does_not_scale_with_task_count(api_client, user): + board = TaskBoard.objects.create(name="Большая доска", created_by=user) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + first_task = Task.objects.create( + board=board, + column=column, + title="Первая задача", + created_by=user, + ) + TaskChecklistItem.objects.create( + task=first_task, + title="Пункт без обложки", + position=1000, + is_completed=True, + created_by=user, + ) + TaskExternalLink.objects.create( + task=first_task, + url="https://example.com/performance", + title="Ссылка", + created_by=user, + ) + api_client.force_authenticate(user=user) + url = reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}) + api_client.get(url) + + with CaptureQueriesContext(connection) as small_board_queries: + small_response = api_client.get(url) + assert small_response.status_code == status.HTTP_200_OK + serialized_first_task = next( + task for task in small_response.data["tasks"] if task["id"] == first_task.id + ) + assert serialized_first_task["checklist_total"] == 1 + assert serialized_first_task["checklist_completed"] == 1 + assert serialized_first_task["linked_objects_count"] == 1 + + Task.objects.bulk_create( + [ + Task( + board=board, + column=column, + title=f"Задача {index}", + created_by=user, + ) + for index in range(2, 32) + ] + ) + with CaptureQueriesContext(connection) as large_board_queries: + large_response = api_client.get(url) + + assert large_response.status_code == status.HTTP_200_OK + assert len(large_board_queries) <= len(small_board_queries) + 1 + + def test_user_can_set_personal_default_board(api_client, user): api_client.force_authenticate(user=user) - first_board = api_client.get( - reverse("api:v1:tasks:task-board-default") - ).data + first_board = api_client.get(reverse("api:v1:tasks:task-board-default")).data second_board = TaskBoard.objects.create( name="Персональная доска по умолчанию", created_by=user, @@ -260,25 +317,43 @@ def test_board_groups_are_personal_and_board_placement_is_independent( assert create_response.status_code == status.HTTP_201_CREATED group_id = create_response.data["id"] + second_group_response = api_client.post( + reverse("api:v1:tasks:task-board-group-list"), + {"name": "Производство"}, + format="json", + ) + second_group_id = second_group_response.data["id"] move_response = api_client.post( reverse("api:v1:tasks:task-board-move-to-group", kwargs={"pk": board.id}), {"group": group_id}, format="json", ) assert move_response.status_code == status.HTTP_200_OK + assert move_response.data["group_ids"] == [group_id] assert move_response.data["group_id"] == group_id - assert TaskBoardPlacement.objects.filter( - user=user, - board=board, - group_id=group_id, - ).exists() + groups_response = api_client.post( + reverse("api:v1:tasks:task-board-set-groups", kwargs={"pk": board.id}), + {"groups": [second_group_id, group_id]}, + format="json", + ) + assert groups_response.status_code == status.HTTP_200_OK + assert groups_response.data["group_ids"] == sorted([group_id, second_group_id]) + assert set(board.board_groups.values_list("id", flat=True)) == { + group_id, + second_group_id, + } summary_response = api_client.get( reverse("api:v1:tasks:task-board-list"), {"view": "summary"}, ) assert summary_response.status_code == status.HTTP_200_OK - assert summary_response.data["results"][0]["group_id"] == group_id + assert summary_response.data["results"][0]["group_ids"] == sorted( + [group_id, second_group_id] + ) + assert summary_response.data["results"][0]["group_id"] == min( + group_id, second_group_id + ) api_client.force_authenticate(user=other_user) other_groups_response = api_client.get( @@ -291,8 +366,16 @@ def test_board_groups_are_personal_and_board_placement_is_independent( {"view": "summary"}, ) assert other_summary_response.status_code == status.HTTP_200_OK + assert other_summary_response.data["results"][0]["group_ids"] == [] assert other_summary_response.data["results"][0]["group_id"] is None + foreign_groups_response = api_client.post( + reverse("api:v1:tasks:task-board-set-groups", kwargs={"pk": board.id}), + {"groups": [group_id]}, + format="json", + ) + assert foreign_groups_response.status_code == status.HTTP_400_BAD_REQUEST + foreign_move_response = api_client.post( reverse("api:v1:tasks:task-board-move-to-group", kwargs={"pk": board.id}), {"group": group_id}, @@ -306,13 +389,182 @@ def test_board_groups_are_personal_and_board_placement_is_independent( ) assert delete_response.status_code == status.HTTP_204_NO_CONTENT assert not TaskBoardGroup.objects.filter(id=group_id).exists() - assert not TaskBoardPlacement.objects.filter(user=user, board=board).exists() + assert list(board.board_groups.values_list("id", flat=True)) == [second_group_id] - ungrouped_summary_response = api_client.get( + grouped_summary_response = api_client.get( reverse("api:v1:tasks:task-board-list"), {"view": "summary"}, ) - assert ungrouped_summary_response.data["results"][0]["group_id"] is None + assert grouped_summary_response.data["results"][0]["group_ids"] == [ + second_group_id + ] + + second_delete_response = api_client.delete( + reverse( + "api:v1:tasks:task-board-group-detail", + kwargs={"pk": second_group_id}, + ) + ) + assert second_delete_response.status_code == status.HTTP_204_NO_CONTENT + assert not TaskBoardPlacement.objects.filter(user=user, board=board).exists() + + +def test_shared_board_group_is_collaborative_without_granting_board_access( + api_client, +): + owner = make_user("shared-group-owner@example.com", "+79994440110") + first_member = make_user("shared-group-first@example.com", "+79994440111") + second_member = make_user("shared-group-second@example.com", "+79994440112") + role_member = make_user("shared-group-role@example.com", "+79994440113") + outsider = make_user("shared-group-outsider@example.com", "+79994440114") + department = Department.objects.create(name="Общий проектный отдел") + role = department.roles.create(name="Участник общей группы") + RoleAssignment.objects.create( + employee=role_member, + role=role, + is_active=True, + ) + first_board = TaskBoard.objects.create( + name="Первая закрытая доска", + created_by=owner, + access_scope="restricted", + ) + first_board.members.add(first_member) + role_board = TaskBoard.objects.create( + name="Доска отдела", + created_by=owner, + access_scope="restricted", + ) + role_board.departments.add(department) + second_board = TaskBoard.objects.create( + name="Добавленная участником доска", + created_by=first_member, + access_scope="restricted", + ) + second_board.members.add(second_member) + + api_client.force_authenticate(user=owner) + create_response = api_client.post( + reverse("api:v1:tasks:task-board-group-list"), + {"name": "Общая работа", "is_shared": True}, + format="json", + ) + assert create_response.status_code == status.HTTP_201_CREATED + group_id = create_response.data["id"] + for board in (first_board, role_board): + response = api_client.post( + reverse("api:v1:tasks:task-board-set-groups", kwargs={"pk": board.id}), + {"groups": [group_id]}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + + api_client.force_authenticate(user=first_member) + group_response = api_client.get( + reverse("api:v1:tasks:task-board-group-list") + ) + assert group_response.data["results"][0]["id"] == group_id + assert group_response.data["results"][0]["boards_count"] == 1 + board_response = api_client.get( + reverse("api:v1:tasks:task-board-list"), + {"view": "summary"}, + ) + assert {item["id"] for item in board_response.data["results"]} == { + first_board.id, + second_board.id, + } + assert next( + item for item in board_response.data["results"] if item["id"] == first_board.id + )["group_ids"] == [group_id] + assert next( + item for item in board_response.data["results"] if item["id"] == second_board.id + )["group_ids"] == [] + + add_response = api_client.post( + reverse( + "api:v1:tasks:task-board-set-groups", + kwargs={"pk": second_board.id}, + ), + {"groups": [group_id]}, + format="json", + ) + assert add_response.status_code == status.HTTP_200_OK + rename_response = api_client.patch( + reverse("api:v1:tasks:task-board-group-detail", kwargs={"pk": group_id}), + {"name": "Переименована участником"}, + format="json", + ) + assert rename_response.status_code == status.HTTP_200_OK + + hide_response = api_client.post( + reverse("api:v1:tasks:task-board-group-toggle-hidden", kwargs={"pk": group_id}) + ) + assert hide_response.status_code == status.HTTP_200_OK + assert hide_response.data["is_hidden"] is True + hidden_boards_response = api_client.get( + reverse("api:v1:tasks:task-board-list"), + {"view": "summary"}, + ) + assert all( + group_id not in item["group_ids"] + for item in hidden_boards_response.data["results"] + ) + unhide_response = api_client.post( + reverse("api:v1:tasks:task-board-group-toggle-hidden", kwargs={"pk": group_id}) + ) + assert unhide_response.data["is_hidden"] is False + default_response = api_client.post( + reverse("api:v1:tasks:task-board-group-set-default", kwargs={"pk": group_id}) + ) + assert default_response.status_code == status.HTTP_200_OK + assert TaskUserSettings.objects.get(user=first_member).default_group_id == group_id + remove_board_response = api_client.post( + reverse( + "api:v1:tasks:task-board-set-groups", + kwargs={"pk": first_board.id}, + ), + {"groups": []}, + format="json", + ) + assert remove_board_response.status_code == status.HTTP_200_OK + assert not first_board.board_groups.filter(id=group_id).exists() + + api_client.force_authenticate(user=role_member) + role_groups_response = api_client.get( + reverse("api:v1:tasks:task-board-group-list") + ) + assert role_groups_response.data["results"][0]["id"] == group_id + role_boards_response = api_client.get( + reverse("api:v1:tasks:task-board-list"), + {"view": "summary"}, + ) + assert [item["id"] for item in role_boards_response.data["results"]] == [ + role_board.id + ] + assert role_boards_response.data["results"][0]["group_ids"] == [group_id] + + api_client.force_authenticate(user=outsider) + outsider_groups_response = api_client.get( + reverse("api:v1:tasks:task-board-group-list") + ) + assert outsider_groups_response.data["results"] == [] + outsider_update_response = api_client.patch( + reverse("api:v1:tasks:task-board-group-detail", kwargs={"pk": group_id}), + {"name": "Недоступное изменение"}, + format="json", + ) + assert outsider_update_response.status_code == status.HTTP_404_NOT_FOUND + + api_client.force_authenticate(user=second_member) + second_groups_response = api_client.get( + reverse("api:v1:tasks:task-board-group-list") + ) + assert second_groups_response.data["results"][0]["id"] == group_id + delete_response = api_client.delete( + reverse("api:v1:tasks:task-board-group-detail", kwargs={"pk": group_id}) + ) + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + assert not TaskBoardGroup.objects.filter(id=group_id).exists() def test_user_can_toggle_personal_default_board_group(api_client, user): @@ -333,9 +585,7 @@ def test_user_can_toggle_personal_default_board_group(api_client, user): assert set_response.data["group"]["is_default_for_current_user"] is True assert TaskUserSettings.objects.get(user=user).default_group_id == first_group.id - list_response = api_client.get( - reverse("api:v1:tasks:task-board-group-list") - ) + list_response = api_client.get(reverse("api:v1:tasks:task-board-group-list")) groups_by_id = {item["id"]: item for item in list_response.data["results"]} assert groups_by_id[first_group.id]["is_default_for_current_user"] is True assert groups_by_id[second_group.id]["is_default_for_current_user"] is False @@ -402,7 +652,6 @@ def test_board_pin_and_notifications_are_personal_and_keep_ungrouped_settings( assert notifications_response.status_code == status.HTTP_200_OK assert notifications_response.data["notifications_enabled"] is False placement = TaskBoardPlacement.objects.get(user=user, board=pinned_board) - assert placement.group_id is None assert placement.is_pinned is True assert placement.notifications_enabled is False @@ -428,7 +677,6 @@ def test_board_pin_and_notifications_are_personal_and_keep_ungrouped_settings( format="json", ) placement.refresh_from_db() - assert placement.group_id is None assert placement.is_pinned is True assert placement.notifications_enabled is False @@ -604,6 +852,7 @@ def test_board_view_state_rejects_objects_from_another_board(api_client, user): assert response.status_code == status.HTTP_400_BAD_REQUEST assert not TaskBoardPlacement.objects.filter(user=user, board=board).exists() + def test_user_cannot_set_inaccessible_board_as_default(api_client): owner = make_user("task-default-owner@example.com", "+79994440091") outsider = make_user("task-default-outsider@example.com", "+79994440092") @@ -979,8 +1228,12 @@ def test_task_row_must_belong_to_tasks_top_level_column(api_client, user): def test_task_moves_between_rows_without_changing_column(api_client, user): board = TaskBoard.objects.create(name="Строки ячеек", created_by=user) parent = TaskColumn.objects.create(board=board, name="Производство", position=1000) - first_row = TaskColumnRow.objects.create(column=parent, name="Линия 1", position=1000) - second_row = TaskColumnRow.objects.create(column=parent, name="Линия 2", position=2000) + first_row = TaskColumnRow.objects.create( + column=parent, name="Линия 1", position=1000 + ) + second_row = TaskColumnRow.objects.create( + column=parent, name="Линия 2", position=2000 + ) child = TaskColumn.objects.create( board=board, parent=parent, @@ -997,18 +1250,20 @@ def test_task_moves_between_rows_without_changing_column(api_client, user): ) api_client.force_authenticate(user=user) - response = api_client.post( - reverse("api:v1:tasks:task-move", kwargs={"pk": task.id}), - { - "column": child.id, - "row": second_row.id, - }, - format="json", - ) + with patch("api.v1.tasks.views.normalize_column_positions") as normalize: + response = api_client.post( + reverse("api:v1:tasks:task-move", kwargs={"pk": task.id}), + { + "column": child.id, + "row": second_row.id, + }, + format="json", + ) assert response.status_code == status.HTTP_200_OK assert response.data["column"] == child.id assert response.data["row"] == second_row.id + normalize.assert_not_called() task.refresh_from_db() assert task.column_id == child.id assert task.row_id == second_row.id @@ -1022,6 +1277,41 @@ def test_task_moves_between_rows_without_changing_column(api_client, user): assert task.row_id is None +def test_appending_task_to_large_column_uses_constant_query_count(user): + board = TaskBoard.objects.create(name="Большая доска", created_by=user) + source = TaskColumn.objects.create(board=board, name="Источник", position=1000) + target = TaskColumn.objects.create(board=board, name="Назначение", position=2000) + task = Task.objects.create( + board=board, + column=source, + title="Перемещаемая задача", + created_by=user, + position=1000, + ) + Task.objects.bulk_create([ + Task( + board=board, + column=target, + title=f"Задача {index}", + created_by=user, + position=(index + 1) * 1000, + ) + for index in range(250) + ]) + + with ( + patch("api.v1.tasks.views.create_task_activity"), + patch("api.v1.tasks.views.notify_task_moved"), + patch("api.v1.tasks.views.send_task_board_update"), + CaptureQueriesContext(connection) as queries, + ): + moved = TaskViewSet()._move_task(task, target, None, None, user) + + assert moved.column_id == target.id + assert moved.position == 251000 + assert len(queries) <= 10 + + def test_reorder_subcolumns_does_not_change_top_level_order(api_client, user): board = TaskBoard.objects.create(name="Порядок подколонок", created_by=user) first_parent = TaskColumn.objects.create( @@ -1147,9 +1437,7 @@ def test_user_cannot_delete_occupied_or_last_task_column(api_client, user): 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_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"]} @@ -1188,6 +1476,7 @@ def test_create_task_and_move_to_done_column(api_client, user): assert send_kwargs["extra"] == { "column_id": columns["Готово"]["id"], "row_id": None, + "position": move_response.data["position"], } task = Task.objects.get(id=task_id) @@ -1269,7 +1558,9 @@ def test_member_can_claim_unassigned_task(api_client, user): reverse("api:v1:tasks:task-claim", kwargs={"pk": completed_task.id}) ) assert completed_response.status_code == status.HTTP_400_BAD_REQUEST - assert completed_response.data["detail"] == "Нельзя взять в работу завершённую задачу." + assert ( + completed_response.data["detail"] == "Нельзя взять в работу завершённую задачу." + ) completed_task.refresh_from_db() assert completed_task.assignee_id is None @@ -1318,11 +1609,14 @@ def test_only_assignee_can_complete_task_in_configured_done_column(api_client, u configured_done_column.refresh_from_db() assert previous_done_column.is_done is False assert configured_done_column.is_done is True - assert TaskColumn.objects.filter( - board=board, - parent__isnull=True, - is_done=True, - ).count() == 1 + assert ( + TaskColumn.objects.filter( + board=board, + parent__isnull=True, + is_done=True, + ).count() + == 1 + ) api_client.force_authenticate(user=member) forbidden_response = api_client.post(complete_url) @@ -1617,7 +1911,9 @@ def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared( ) second_image = TaskAttachment.objects.create( task=task, - file=SimpleUploadedFile("second.jpg", b"second image", content_type="image/jpeg"), + file=SimpleUploadedFile( + "second.jpg", b"second image", content_type="image/jpeg" + ), file_name="second.jpg", file_size=12, mime_type="image/jpeg", @@ -1666,6 +1962,9 @@ def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared( item for item in board_response.data["tasks"] if item["id"] == task.id ) assert serialized_task["cover_attachment"]["id"] == second_image.id + assert serialized_task["cover_attachment"]["thumbnail_url"].endswith( + f"/api/v1/tasks/{task.id}/attachments/{second_image.id}/thumbnail/" + ) video_response = api_client.post( cover_url, @@ -1702,6 +2001,61 @@ def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared( assert not TaskCover.objects.filter(task=task).exists() +def test_task_attachment_thumbnail_is_generated_once_and_requires_access( + api_client, + user, + tmp_path, + settings, +): + settings.MEDIA_ROOT = tmp_path + outsider = make_user("thumbnail-outsider@example.com", "+79994440126") + board = TaskBoard.objects.create( + name="Доска миниатюр", + created_by=user, + access_scope="restricted", + ) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + task = Task.objects.create( + board=board, + column=column, + title="Задача с миниатюрой", + created_by=user, + ) + image_buffer = io.BytesIO() + Image.new("RGB", (80, 40), (20, 140, 220)).save(image_buffer, format="PNG") + image_data = image_buffer.getvalue() + attachment = TaskAttachment.objects.create( + task=task, + file=SimpleUploadedFile("cover.png", image_data, content_type="image/png"), + file_name="cover.png", + file_size=len(image_data), + mime_type="image/png", + uploaded_by=user, + ) + thumbnail_url = reverse( + "api:v1:tasks:task-attachment-thumbnail", + kwargs={"pk": task.id, "attachment_id": attachment.id}, + ) + + api_client.force_authenticate(user=user) + first_response = api_client.get(thumbnail_url) + + assert first_response.status_code == status.HTTP_200_OK + assert first_response["Content-Type"] == "image/webp" + assert b"WEBP" in b"".join(first_response.streaming_content)[:16] + attachment.refresh_from_db() + saved_thumbnail_name = attachment.thumbnail.name + assert saved_thumbnail_name.endswith(".webp") + + second_response = api_client.get(thumbnail_url) + assert second_response.status_code == status.HTTP_200_OK + attachment.refresh_from_db() + assert attachment.thumbnail.name == saved_thumbnail_name + + api_client.force_authenticate(user=outsider) + assert api_client.get(thumbnail_url).status_code == status.HTTP_404_NOT_FOUND + + def test_task_cover_supports_checklist_comment_link_and_linked_object( api_client, user, @@ -1815,9 +2169,7 @@ def test_task_cover_supports_checklist_comment_link_and_linked_object( format="json", ) assert link_response.status_code == status.HTTP_200_OK - assert link_response.data["cover"]["external_link"]["title"] == ( - "Пример ссылки" - ) + assert link_response.data["cover"]["external_link"]["title"] == ("Пример ссылки") object_response = api_client.post( cover_url, @@ -2075,9 +2427,7 @@ def test_private_board_is_available_only_to_owner_and_admin(api_client): api_client.force_authenticate(user=hidden_user) list_response = api_client.get(reverse("api:v1:tasks:task-board-list")) assert list_response.status_code == status.HTTP_200_OK - assert board.id not in { - item["id"] for item in list_response.data["results"] - } + assert board.id not in {item["id"] for item in list_response.data["results"]} detail_response = api_client.get( reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}) ) @@ -2423,9 +2773,7 @@ def test_task_linked_document_respects_document_access(api_client): assert link_response.data["document"]["title"] == "Регламент доступа" assert link_response.data["document"]["is_regulation"] is True assert link_response.data["can_open"] is True - assert link_response.data["object_url"] == ( - f"/documents?document={document.id}" - ) + assert link_response.data["object_url"] == (f"/documents?document={document.id}") api_client.force_authenticate(user=board_member) linked_response = api_client.get( @@ -2451,9 +2799,7 @@ def test_task_linked_document_respects_document_access(api_client): assert reverse_linked_response.status_code == status.HTTP_200_OK assert reverse_linked_response.data[0]["id"] == task.id assert reverse_linked_response.data[0]["title"] == "Проверить регламент" - assert ( - reverse_linked_response.data[0]["linked_documents_count"] == 1 - ) + assert reverse_linked_response.data[0]["linked_documents_count"] == 1 def test_task_linked_request_respects_request_access(api_client): @@ -2528,9 +2874,7 @@ def test_task_linked_request_respects_request_access(api_client): assert reverse_linked_response.status_code == status.HTTP_200_OK assert reverse_linked_response.data[0]["id"] == task.id assert reverse_linked_response.data[0]["title"] == "Проверить заявление" - assert ( - reverse_linked_response.data[0]["linked_requests_count"] == 1 - ) + assert reverse_linked_response.data[0]["linked_requests_count"] == 1 def test_task_linked_procurement_request_respects_procurement_access(api_client): @@ -2585,7 +2929,9 @@ def test_task_linked_procurement_request_respects_procurement_access(api_client) ) assert link_response.status_code == status.HTTP_201_CREATED assert link_response.data["procurement_request"]["title"] == "Закупка мониторов" - assert link_response.data["procurement_request"]["status"] == ProcurementStatus.DRAFT + assert ( + link_response.data["procurement_request"]["status"] == ProcurementStatus.DRAFT + ) assert link_response.data["procurement_request"]["urgency"] == UrgencyLevel.HIGH assert link_response.data["can_open"] is True assert link_response.data["object_url"] == ( @@ -2991,9 +3337,7 @@ def test_task_linked_attendance_record_respects_record_access(api_client): 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_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] @@ -3206,9 +3550,7 @@ def test_task_comments_use_communications_and_update_count(api_client, user): ) assert detail_response.data["comments_count"] == 1 - delete_response = api_client.delete( - comment_detail_url - ) + delete_response = api_client.delete(comment_detail_url) assert delete_response.status_code == status.HTTP_204_NO_CONTENT message.refresh_from_db() assert message.is_deleted is True @@ -3228,12 +3570,18 @@ def test_task_comments_use_communications_and_update_count(api_client, user): "comment_added", ] - assert api_client.get( - reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}) - ).data == [] - assert api_client.get( - reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}) - ).data["comments_count"] == 0 + assert ( + api_client.get( + reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}) + ).data + == [] + ) + assert ( + api_client.get( + reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}) + ).data["comments_count"] + == 0 + ) def test_task_comments_chat_access_follows_board_access(api_client): @@ -3315,10 +3663,13 @@ def test_task_comment_files_and_links_become_task_resources(api_client, user): assert attachment.comment_id == response.data["id"] assert attachment.mime_type == "image/png" assert attachment_data["comment"] == response.data["id"] - assert TaskExternalLink.objects.filter( - task=task, - url="https://example.com/mockup", - ).count() == 1 + assert ( + TaskExternalLink.objects.filter( + task=task, + url="https://example.com/mockup", + ).count() + == 1 + ) listed = api_client.get( reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}) diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/src/app/api/[...path]/route.ts index 51f256b6..b80296dc 100644 --- a/frontend/src/app/api/[...path]/route.ts +++ b/frontend/src/app/api/[...path]/route.ts @@ -89,13 +89,25 @@ async function proxyRequest(request: NextRequest, method: string) { }); } - // arrayBuffer сохраняет бинарные ответы (.xlsx, изображения) и работает - // для JSON/текста без потери заголовков. + // Node fetch может распаковать upstream-ответ, поэтому исходные + // content-encoding/content-length больше не соответствуют телу. responseHeaders.delete('content-encoding'); responseHeaders.delete('content-length'); responseHeaders.delete('transfer-encoding'); - // Возвращаем ответ от backend + // GET-ответы (включая крупные JSON, изображения и скачивания) передаём + // потоком. Раньше arrayBuffer() задерживал первый байт до полной загрузки + // ответа и держал весь файл/ответ доски в памяти процесса Next.js. + if (method === 'GET') { + return new NextResponse(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } + + // Для изменяющих запросов пока сохраняем прежнее буферизованное + // поведение: их ответы небольшие, а риск изменения upload-потока выше. const responseBody = await response.arrayBuffer(); return new NextResponse(responseBody, { diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 918532b1..23264cf2 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1133,6 +1133,13 @@ html[data-bs-theme="dark"] .tasks-board-group-header-sticky { -ms-overflow-style: auto; } +/* Карточка остаётся в DOM с полной высотой для стабильного скролла, но браузер + может не выполнять её layout/paint далеко за пределами видимой области. */ +.tasks-task-card { + content-visibility: auto; + contain-intrinsic-size: auto 10rem; +} + .tasks-board-scroll::-webkit-scrollbar, .tasks-column-scroll::-webkit-scrollbar { display: block; diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 42c0ba6f..79fcffbd 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -67,11 +67,14 @@ import { } from "lucide-react"; import { Suspense, + memo, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, + type CSSProperties, type ReactNode, type RefObject, } from "react"; @@ -158,6 +161,7 @@ type BoardFormState = { type BoardGroupFormState = { id: number | null; name: string; + is_shared: boolean; }; type ColumnFormState = { @@ -277,6 +281,7 @@ const emptyBoardForm: BoardFormState = { const emptyBoardGroupForm: BoardGroupFormState = { id: null, name: "", + is_shared: false, }; const taskDescriptionPreviewClass = @@ -441,6 +446,25 @@ function formatFileSize(size: number): string { })} МБ`; } +function nextTaskPosition( + tasks: TaskCard[], + columnId: number, + rowId: number | null, + excludedTaskId?: number, +): number { + let maxPosition = 0; + for (const task of tasks) { + if ( + task.id !== excludedTaskId + && task.column === columnId + && (task.row || null) === rowId + ) { + maxPosition = Math.max(maxPosition, task.position); + } + } + return maxPosition + 1000; +} + function isImageAttachment(attachment: TaskAttachment): boolean { if (attachment.mime_type?.toLowerCase().startsWith("image/")) return true; return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(attachment.file_name); @@ -612,20 +636,7 @@ function getMessageCreatedLabel(message?: Message | null) { return message.created || (message.created_at ? formatDate(message.created_at) : ""); } -function TaskCardView({ - task, - onOpen, - onEdit, - onDelete, - currentUserId, - claiming, - onClaim, - completing, - onComplete, - menuOpen, - menuRef, - onToggleMenu, -}: { +type TaskCardViewProps = { task: TaskCard; onOpen: (task: TaskCard) => void; onEdit: (task: TaskCard) => void; @@ -636,14 +647,43 @@ function TaskCardView({ completing: boolean; onComplete: (task: TaskCard) => void; menuOpen: boolean; - menuRef: RefObject; - onToggleMenu: () => void; -}) { - const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ - id: `task-${task.id}`, - data: { type: "task", taskId: task.id }, - }); - const style = transform && !isDragging ? { transform: CSS.Translate.toString(transform) } : undefined; + onToggleTaskMenu: (taskId: number, anchor: HTMLButtonElement) => void; +}; + +type TaskCardDragBinding = { + attributes?: ReturnType["attributes"]; + listeners?: ReturnType["listeners"]; + setNodeRef?: ReturnType["setNodeRef"]; + style?: CSSProperties; + isDragging?: boolean; + onPointerEnter?: () => void; + onPointerLeave?: () => void; + onPointerDownCapture?: () => void; + onPointerUpCapture?: () => void; + onPointerCancelCapture?: () => void; +}; + +const TaskCardContent = memo(function TaskCardContent({ + task, + onOpen, + currentUserId, + claiming, + onClaim, + completing, + onComplete, + menuOpen, + onToggleTaskMenu, + attributes, + listeners, + setNodeRef, + style, + isDragging = false, + onPointerEnter, + onPointerLeave, + onPointerDownCapture, + onPointerUpCapture, + onPointerCancelCapture, +}: TaskCardViewProps & TaskCardDragBinding) { const priority = priorityMeta[task.priority] ?? priorityMeta.medium; const dueDateClass = getTaskDueDateBadgeClass(task); const canClaim = !task.assignee && !task.completed_at; @@ -653,11 +693,16 @@ function TaskCardView({
onOpen(task)} + onPointerEnter={onPointerEnter} + onPointerLeave={onPointerLeave} + onPointerDownCapture={onPointerDownCapture} + onPointerUpCapture={onPointerUpCapture} + onPointerCancelCapture={onPointerCancelCapture} {...attributes} {...listeners} > @@ -682,14 +727,13 @@ function TaskCardView({
event.stopPropagation()} onClick={(event) => event.stopPropagation()} > - {menuOpen ? ( -
- - -
- ) : null}
{priority.urgencyLabel} срочность @@ -822,8 +846,88 @@ function TaskCardView({
); +}); + +function TaskCardDraggableBinding({ + task, + onBindingChange, + onDraggingChange, +}: { + task: TaskCard; + onBindingChange: (binding: TaskCardDragBinding | null) => void; + onDraggingChange: (isDragging: boolean) => void; +}) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `task-${task.id}`, + data: { type: "task", taskId: task.id }, + }); + const binding = useMemo(() => ({ + attributes, + listeners, + setNodeRef, + isDragging, + }), [attributes, isDragging, listeners, setNodeRef]); + + useLayoutEffect(() => { + onBindingChange(binding); + return () => onBindingChange(null); + }, [binding, onBindingChange]); + + useEffect(() => { + onDraggingChange(isDragging); + }, [isDragging, onDraggingChange]); + + return null; } +const TaskCardView = memo(function TaskCardView(props: TaskCardViewProps) { + const [dragArmed, setDragArmed] = useState(false); + const [dragBinding, setDragBinding] = useState(null); + const pointerInsideRef = useRef(false); + const pointerPressedRef = useRef(false); + const draggingRef = useRef(false); + const handleDraggingChange = useCallback((isDragging: boolean) => { + draggingRef.current = isDragging; + if (!isDragging && !pointerInsideRef.current) setDragArmed(false); + }, []); + + return ( + <> + {dragArmed ? ( + + ) : null} + { + pointerInsideRef.current = true; + setDragArmed(true); + }} + onPointerLeave={() => { + pointerInsideRef.current = false; + if (!pointerPressedRef.current && !draggingRef.current) setDragArmed(false); + }} + onPointerDownCapture={() => { + pointerPressedRef.current = true; + }} + onPointerUpCapture={() => { + pointerPressedRef.current = false; + if (!pointerInsideRef.current && !draggingRef.current) setDragArmed(false); + }} + onPointerCancelCapture={() => { + pointerPressedRef.current = false; + if (!pointerInsideRef.current && !draggingRef.current) setDragArmed(false); + }} + /> + + ); +}); + function TaskDragOverlayCard({ task }: { task: TaskCard }) { const priority = priorityMeta[task.priority] ?? priorityMeta.medium; const dueDateClass = getTaskDueDateBadgeClass(task); @@ -1091,6 +1195,81 @@ function TaskAddMenuPopover({ ); } +function TaskCardMenuPopover({ + anchorRef, + menuRef, + children, +}: { + anchorRef: RefObject; + menuRef: RefObject; + children: ReactNode; +}) { + const [position, setPosition] = useState<{ + left: number; + top?: number; + bottom?: number; + maxHeight: number; + } | null>(null); + + useEffect(() => { + const updatePosition = () => { + const anchor = anchorRef.current; + if (!anchor) return; + + const rect = anchor.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const menuWidth = 176; + const viewportGap = 8; + const menuGap = 6; + const spaceBelow = viewportHeight - rect.bottom - viewportGap - menuGap; + const spaceAbove = rect.top - viewportGap - menuGap; + const placeBelow = spaceBelow >= 96 || spaceBelow >= spaceAbove; + const left = Math.min( + Math.max(viewportGap, rect.right - menuWidth), + Math.max(viewportGap, viewportWidth - menuWidth - viewportGap), + ); + + setPosition(placeBelow ? { + left, + top: rect.bottom + menuGap, + maxHeight: Math.max(72, spaceBelow), + } : { + left, + bottom: viewportHeight - rect.top + menuGap, + maxHeight: Math.max(72, spaceAbove), + }); + }; + + updatePosition(); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + return () => { + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [anchorRef]); + + if (!position || typeof document === "undefined") return null; + + return createPortal( +
+ {children} +
, + document.body, + ); +} + type TaskColumnCardsProps = { tasks: TaskCard[]; onOpenTask: (task: TaskCard) => void; @@ -1102,8 +1281,7 @@ type TaskColumnCardsProps = { completingTaskId: number | null; onCompleteTask: (task: TaskCard) => void; openMenuTaskId: number | null; - menuRef: RefObject; - onToggleTaskMenu: (taskId: number) => void; + onToggleTaskMenu: (taskId: number, anchor: HTMLButtonElement) => void; quickTaskComposer?: ReactNode; }; @@ -1118,7 +1296,6 @@ function TaskColumnCards({ completingTaskId, onCompleteTask, openMenuTaskId, - menuRef, onToggleTaskMenu, quickTaskComposer, }: TaskColumnCardsProps) { @@ -1139,8 +1316,7 @@ function TaskColumnCards({ completing={completingTaskId === task.id} onComplete={onCompleteTask} menuOpen={openMenuTaskId === task.id} - menuRef={menuRef} - onToggleMenu={() => onToggleTaskMenu(task.id)} + onToggleTaskMenu={onToggleTaskMenu} /> )) ) : quickTaskComposer ? null : ( @@ -1166,7 +1342,6 @@ function BoardColumn({ completingTaskId, onCompleteTask, openMenuTaskId, - menuRef, onToggleTaskMenu, columnMenuOpen, columnMenuRef, @@ -1197,8 +1372,7 @@ function BoardColumn({ completingTaskId: number | null; onCompleteTask: (task: TaskCard) => void; openMenuTaskId: number | null; - menuRef: RefObject; - onToggleTaskMenu: (taskId: number) => void; + onToggleTaskMenu: (taskId: number, anchor: HTMLButtonElement) => void; columnMenuOpen: boolean; columnMenuRef: RefObject; onToggleColumnMenu: () => void; @@ -1392,7 +1566,6 @@ function BoardColumn({ completingTaskId={completingTaskId} onCompleteTask={onCompleteTask} openMenuTaskId={openMenuTaskId} - menuRef={menuRef} onToggleTaskMenu={onToggleTaskMenu} quickTaskComposer={quickTaskComposer} /> @@ -3645,6 +3818,7 @@ function TasksPageContent() { const taskAddMenuBottomRef = useRef(null); const taskAddMenuPopoverRef = useRef(null); const viewColumnMenuRef = useRef(null); + const cardMenuAnchorRef = useRef(null); const cardMenuRef = useRef(null); const columnMenuRef = useRef(null); const selectedBoardIdRef = useRef(null); @@ -3652,6 +3826,7 @@ function TasksPageContent() { const pendingBoardViewStateRef = useRef>(new Map()); const columnNodeRefs = useRef>(new Map()); const taskAttachmentInputRef = useRef(null); + const taskCoverUploadInputRef = useRef(null); const taskFormAttachmentInputRef = useRef(null); const taskChecklistDraftRef = useRef(null); const taskFormChecklistDraftRef = useRef(null); @@ -3759,6 +3934,33 @@ function TasksPageContent() { return data; }, []); + const replaceBoardTask = useCallback((updatedTask: TaskCard) => { + setBoard((current) => { + if (!current || current.id !== updatedTask.board) return current; + return { + ...current, + tasks: current.tasks.map((task) => ( + task.id === updatedTask.id ? updatedTask : task + )), + }; + }); + }, []); + + const updateBoardTask = useCallback(( + taskId: number, + update: (task: TaskCard) => TaskCard, + ) => { + setBoard((current) => { + if (!current) return current; + return { + ...current, + tasks: current.tasks.map((task) => ( + task.id === taskId ? update(task) : task + )), + }; + }); + }, []); + const toggleBoardViewState = useCallback(( scope: BoardViewStateScope, objectId: number, @@ -4130,6 +4332,14 @@ function TasksPageContent() { onChecklistChange: (taskId) => void loadTaskChecklist(taskId), onCommentsChange: (taskId) => void loadTaskComments(taskId), onActivityChange: (taskId) => void loadTaskActivity(taskId), + onTaskMove: (taskId, columnId, rowId, position) => { + updateBoardTask(taskId, (task) => ({ + ...task, + column: columnId, + row: rowId, + position: position ?? task.position, + })); + }, onBoardChange: (boardId) => void syncTaskBoardState(boardId), }); @@ -4283,8 +4493,8 @@ function TasksPageContent() { function handlePointerDown(event: MouseEvent) { if ( - cardMenuRef.current && - !cardMenuRef.current.contains(event.target as Node) + !cardMenuRef.current?.contains(event.target as Node) + && !cardMenuAnchorRef.current?.contains(event.target as Node) ) { setCardMenuTaskId(null); } @@ -4414,6 +4624,11 @@ function TasksPageContent() { [activeTaskId, board?.tasks], ); + const cardMenuTask = useMemo( + () => (board?.tasks || []).find((task) => task.id === cardMenuTaskId) || null, + [board?.tasks, cardMenuTaskId], + ); + const viewTask = useMemo( () => (board?.tasks || []).find((task) => task.id === viewTaskId) || null, [board?.tasks, viewTaskId], @@ -4787,8 +5002,15 @@ function TasksPageContent() { void loadTaskAutomationButtons(task.id); }, [loadTaskAutomationButtons]); - const toggleCardTaskMenu = useCallback((taskId: number) => { - setCardMenuTaskId((current) => (current === taskId ? null : taskId)); + const toggleCardTaskMenu = useCallback(( + taskId: number, + anchor: HTMLButtonElement, + ) => { + setCardMenuTaskId((current) => { + const next = current === taskId ? null : taskId; + cardMenuAnchorRef.current = next ? anchor : null; + return next; + }); }, []); const toggleColumnMenu = useCallback((columnId: number) => { @@ -4951,7 +5173,11 @@ function TasksPageContent() { }, []); const openEditBoardGroup = useCallback((group: TaskBoardGroup) => { - setBoardGroupForm({ id: group.id, name: group.name }); + setBoardGroupForm({ + id: group.id, + name: group.name, + is_shared: group.is_shared, + }); setBoardGroupModalOpen(true); }, []); @@ -5288,11 +5514,17 @@ function TasksPageContent() { setError(null); try { if (boardGroupForm.id) { - await apiClient.updateTaskBoardGroup(boardGroupForm.id, { name }); + await apiClient.updateTaskBoardGroup(boardGroupForm.id, { + name, + is_shared: boardGroupForm.is_shared, + }); } else { - await apiClient.createTaskBoardGroup({ name }); + await apiClient.createTaskBoardGroup({ + name, + is_shared: boardGroupForm.is_shared, + }); } - await loadBoardGroups(); + await Promise.all([loadBoardGroups(), loadBoards()]); setBoardGroupModalOpen(false); setBoardGroupForm(emptyBoardGroupForm); } catch (groupError) { @@ -5300,11 +5532,20 @@ function TasksPageContent() { } finally { setSaving(false); } - }, [boardGroupForm.id, boardGroupForm.name, loadBoardGroups, saving]); + }, [ + boardGroupForm.id, + boardGroupForm.is_shared, + boardGroupForm.name, + loadBoardGroups, + loadBoards, + saving, + ]); const deleteBoardGroup = useCallback(async (group: TaskBoardGroup) => { if (saving) return; - if (!window.confirm(`Удалить группу «${group.name}»? Доски останутся в разделе «Без группы».`)) { + if (!window.confirm( + `Удалить группу «${group.name}»? Она будет убрана у досок, остальные группы сохранятся.`, + )) { return; } setSaving(true); @@ -5338,27 +5579,62 @@ function TasksPageContent() { } }, [saving]); - const moveBoardToGroup = useCallback(async (boardId: number, groupId: number | null) => { - const previousGroupId = boards.find((item) => item.id === boardId)?.group_id || null; - if (previousGroupId === groupId || saving) return false; - setBoards((current) => current.map((item) => ( - item.id === boardId ? { ...item, group_id: groupId } : item - ))); - setBoard((current) => current?.id === boardId ? { ...current, group_id: groupId } : current); + const toggleBoardGroupHidden = useCallback(async (group: TaskBoardGroup) => { + if (saving) return; setSaving(true); setError(null); try { - await apiClient.moveTaskBoardToGroup(boardId, groupId); + await apiClient.toggleTaskBoardGroupHidden(group.id); + await Promise.all([loadBoardGroups(), loadBoards()]); + } catch (groupError) { + setError(getTaskError(groupError, "Не удалось изменить видимость группы")); + } finally { + setSaving(false); + } + }, [loadBoardGroups, loadBoards, saving]); + + const setBoardGroupMemberships = useCallback(async ( + boardId: number, + groupIds: number[], + ) => { + const currentBoard = boards.find((item) => item.id === boardId); + const previousGroupIds = [ + ...(currentBoard?.group_ids || (currentBoard?.group_id ? [currentBoard.group_id] : [])), + ].sort((left, right) => left - right); + const nextGroupIds = [...new Set(groupIds)].sort((left, right) => left - right); + if ( + saving + || ( + previousGroupIds.length === nextGroupIds.length + && previousGroupIds.every((groupId, index) => groupId === nextGroupIds[index]) + ) + ) return false; + const nextGroupId = nextGroupIds[0] || null; + setBoards((current) => current.map((item) => ( + item.id === boardId + ? { ...item, group_ids: nextGroupIds, group_id: nextGroupId } + : item + ))); + setBoard((current) => current?.id === boardId + ? { ...current, group_ids: nextGroupIds, group_id: nextGroupId } + : current); + setSaving(true); + setError(null); + try { + await apiClient.setTaskBoardGroups(boardId, nextGroupIds); await loadBoardGroups(); return true; } catch (groupError) { + const previousGroupId = previousGroupIds[0] || null; setBoards((current) => current.map((item) => ( - item.id === boardId ? { ...item, group_id: previousGroupId } : item + item.id === boardId + ? { ...item, group_ids: previousGroupIds, group_id: previousGroupId } + : item ))); setBoard((current) => current?.id === boardId - ? { ...current, group_id: previousGroupId } + ? { ...current, group_ids: previousGroupIds, group_id: previousGroupId } : current); - setError(getTaskError(groupError, "Не удалось переместить доску")); + setError(getTaskError(groupError, "Не удалось изменить группы доски")); return false; } finally { setSaving(false); @@ -5825,13 +6101,17 @@ function TasksPageContent() { if (viewTaskId === task.id) setViewTaskId(null); setTaskMenuOpen(false); setCardMenuTaskId(null); - await loadBoard(board.id); + setBoard((current) => current ? { + ...current, + tasks: current.tasks.filter((item) => item.id !== task.id), + tasks_count: Math.max(0, (current.tasks_count || current.tasks.length) - 1), + } : current); } catch (deleteError) { setError(getTaskError(deleteError, "Не удалось удалить задачу")); } finally { setSaving(false); } - }, [board, loadBoard, saving, viewTaskId]); + }, [board, saving, viewTaskId]); const deleteViewedTask = useCallback(async () => { if (!viewTask) return; @@ -5846,14 +6126,55 @@ function TasksPageContent() { try { const created = await apiClient.uploadTaskAttachments(viewTask.id, files); setTaskAttachments((current) => [...current, ...created]); - await loadBoard(board.id); + updateBoardTask(viewTask.id, (task) => ({ + ...task, + attachments_count: (task.attachments_count || 0) + created.length, + })); if (activityOpen) await loadTaskActivity(viewTask.id); } catch (attachmentError) { setError(getTaskError(attachmentError, "Не удалось загрузить файлы")); } finally { setTaskAttachmentsUploading(false); } - }, [activityOpen, board, loadBoard, loadTaskActivity, taskAttachmentsUploading, viewTask]); + }, [activityOpen, board, loadTaskActivity, taskAttachmentsUploading, updateBoardTask, viewTask]); + + const uploadViewedTaskCover = useCallback(async (file: File | null) => { + if (!board || !viewTask || !file || saving || taskAttachmentsUploading) return; + if (!isMediaFile(file)) { + setError("Для обложки можно загрузить только изображение или видео"); + return; + } + + setSaving(true); + setTaskAttachmentsUploading(true); + setError(null); + try { + const [attachment] = await apiClient.uploadTaskAttachments(viewTask.id, [file]); + if (!attachment) throw new Error("Файл не был загружен"); + + setTaskAttachments((current) => [...current, attachment]); + const updatedTask = await apiClient.setTaskCover(viewTask.id, { + kind: "attachment", + source_id: attachment.id, + }); + replaceBoardTask(updatedTask); + if (activityOpen) await loadTaskActivity(viewTask.id); + setCoverPickerOpen(false); + } catch (coverError) { + setError(getTaskError(coverError, "Не удалось загрузить и установить обложку задачи")); + } finally { + setTaskAttachmentsUploading(false); + setSaving(false); + } + }, [ + activityOpen, + board, + loadTaskActivity, + replaceBoardTask, + saving, + taskAttachmentsUploading, + viewTask, + ]); const downloadViewedTaskAttachment = useCallback(async (attachment: TaskAttachment) => { if (!viewTask) return; @@ -5885,14 +6206,24 @@ function TasksPageContent() { try { await apiClient.deleteTaskAttachment(viewTask.id, attachment.id); setTaskAttachments((current) => current.filter((item) => item.id !== attachment.id)); - await loadBoard(board.id); + updateBoardTask(viewTask.id, (task) => { + const removedCover = ( + task.cover?.kind === "attachment" + && task.cover.attachment?.id === attachment.id + ); + return { + ...task, + attachments_count: Math.max(0, (task.attachments_count || 0) - 1), + ...(removedCover ? { cover: null, cover_attachment: null } : {}), + }; + }); if (activityOpen) await loadTaskActivity(viewTask.id); } catch (attachmentError) { setError(getTaskError(attachmentError, "Не удалось удалить файл")); } finally { setTaskAttachmentsUploading(false); } - }, [activityOpen, board, loadBoard, loadTaskActivity, taskAttachmentsUploading, viewTask]); + }, [activityOpen, board, loadTaskActivity, taskAttachmentsUploading, updateBoardTask, viewTask]); const toggleViewedTaskCover = useCallback(async (attachment: TaskAttachment) => { if (!board || !viewTask || taskAttachmentsUploading) return; @@ -5904,22 +6235,23 @@ function TasksPageContent() { viewTask.cover?.kind === "attachment" && viewTask.cover.attachment?.id === attachment.id ); + let updatedTask: TaskCard; if (isCurrentCover) { - await apiClient.clearTaskCover(viewTask.id); + updatedTask = await apiClient.clearTaskCover(viewTask.id); } else { - await apiClient.setTaskCover(viewTask.id, { + updatedTask = await apiClient.setTaskCover(viewTask.id, { kind: "attachment", source_id: attachment.id, }); } - await loadBoard(board.id); + replaceBoardTask(updatedTask); if (activityOpen) await loadTaskActivity(viewTask.id); } catch (attachmentError) { setError(getTaskError(attachmentError, "Не удалось изменить обложку задачи")); } finally { setTaskAttachmentsUploading(false); } - }, [activityOpen, board, loadBoard, loadTaskActivity, taskAttachmentsUploading, viewTask]); + }, [activityOpen, board, loadTaskActivity, replaceBoardTask, taskAttachmentsUploading, viewTask]); const openTaskCoverPicker = useCallback(async () => { if (!viewTask || coverPickerLoading) return; @@ -5954,11 +6286,11 @@ function TasksPageContent() { setSaving(true); setError(null); try { - await apiClient.setTaskCover(viewTask.id, { + const updatedTask = await apiClient.setTaskCover(viewTask.id, { kind, ...(sourceId ? { source_id: sourceId } : {}), }); - await loadBoard(board.id); + replaceBoardTask(updatedTask); if (activityOpen) await loadTaskActivity(viewTask.id); setCoverPickerOpen(false); } catch (coverError) { @@ -5966,15 +6298,15 @@ function TasksPageContent() { } finally { setSaving(false); } - }, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]); + }, [activityOpen, board, loadTaskActivity, replaceBoardTask, saving, viewTask]); const clearViewedTaskCover = useCallback(async () => { if (!board || !viewTask || saving) return; setSaving(true); setError(null); try { - await apiClient.clearTaskCover(viewTask.id); - await loadBoard(board.id); + const updatedTask = await apiClient.clearTaskCover(viewTask.id); + replaceBoardTask(updatedTask); if (activityOpen) await loadTaskActivity(viewTask.id); setCoverPickerOpen(false); } catch (coverError) { @@ -5982,7 +6314,7 @@ function TasksPageContent() { } finally { setSaving(false); } - }, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]); + }, [activityOpen, board, loadTaskActivity, replaceBoardTask, saving, viewTask]); const toggleViewedTaskResourceCover = useCallback(async ( kind: TaskCoverType, @@ -6643,16 +6975,28 @@ function TasksPageContent() { && rowsByColumn.get(targetRootId)?.some((item) => item.id === viewTask.row) ? viewTask.row : null; - await apiClient.moveTask(viewTask.id, { column: columnId, row }); - await loadBoard(board.id); + const optimisticPosition = nextTaskPosition( + board.tasks, + columnId, + row, + viewTask.id, + ); + updateBoardTask(viewTask.id, (task) => ({ + ...task, + column: columnId, + row, + position: optimisticPosition, + })); + const updatedTask = await apiClient.moveTask(viewTask.id, { column: columnId, row }); + replaceBoardTask(updatedTask); if (activityOpen) await loadTaskActivity(viewTask.id); } catch (moveError) { setError(getTaskError(moveError, "Не удалось изменить колонку задачи")); - await loadBoard(board.id); + replaceBoardTask(viewTask); } finally { setSaving(false); } - }, [activeColumns, activityOpen, board, loadBoard, loadTaskActivity, rowsByColumn, saving, viewTask]); + }, [activeColumns, activityOpen, board, loadTaskActivity, replaceBoardTask, rowsByColumn, saving, updateBoardTask, viewTask]); const claimTask = useCallback(async (task: TaskCard) => { if ( @@ -6848,14 +7192,30 @@ function TasksPageContent() { const task = board.tasks.find((item) => item.id === taskId); if (!task || (task.column === columnId && (task.row || null) === rowId)) return; + const optimisticPosition = nextTaskPosition( + board.tasks, + columnId, + rowId, + taskId, + ); + updateBoardTask(taskId, (current) => ({ + ...current, + column: columnId, + row: rowId, + position: optimisticPosition, + })); + try { - await apiClient.moveTask(taskId, { column: columnId, row: rowId }); - await loadBoard(board.id); + const updatedTask = await apiClient.moveTask(taskId, { + column: columnId, + row: rowId, + }); + replaceBoardTask(updatedTask); } catch (moveError) { setError(getTaskError(moveError, "Не удалось переместить задачу")); - await loadBoard(board.id); + replaceBoardTask(task); } - }, [activeColumns, board, loadBoard]); + }, [activeColumns, board, loadBoard, replaceBoardTask, updateBoardTask]); const taskAddMenuItems = viewTask ? ( <> @@ -7011,12 +7371,13 @@ function TasksPageContent() { onEditGroup={openEditBoardGroup} onDeleteGroup={deleteBoardGroup} onToggleDefaultGroup={(group) => void toggleDefaultBoardGroup(group)} + onToggleHiddenGroup={(group) => void toggleBoardGroupHidden(group)} onEditBoard={openEditBoard} onDeleteBoard={(targetBoard) => void deleteBoard(targetBoard)} onSetDefaultBoard={(targetBoard) => void setDefaultBoard(targetBoard)} onToggleBoardPin={(targetBoard) => void toggleBoardPin(targetBoard)} onToggleBoardNotifications={(targetBoard) => void toggleBoardNotifications(targetBoard)} - onMoveBoard={moveBoardToGroup} + onMoveBoard={setBoardGroupMemberships} wideMode={desktopWideMode} onWideModeChange={changeDesktopWideMode} userId={user?.id ?? null} @@ -7315,7 +7676,6 @@ function TasksPageContent() { completingTaskId={completingTaskId} onCompleteTask={completeTask} openMenuTaskId={cardMenuTaskId} - menuRef={cardMenuRef} onToggleTaskMenu={toggleCardTaskMenu} columnMenuOpen={columnMenuId === column.id} columnMenuRef={columnMenuRef} @@ -7375,7 +7735,6 @@ function TasksPageContent() { completingTaskId={completingTaskId} onCompleteTask={completeTask} openMenuTaskId={cardMenuTaskId} - menuRef={cardMenuRef} onToggleTaskMenu={toggleCardTaskMenu} linkedColumnHighlight={activeColumnDragId === cellColumn.id} synchronizedColumnDrag={activeColumnDragId !== null} @@ -7489,6 +7848,32 @@ function TasksPageContent() { {activeDragTask ? : null} + + {cardMenuTask ? ( + + + + + ) : null} @@ -7776,22 +8161,43 @@ function TasksPageContent() { )} > - +
+ + +
)} > + { + const file = event.currentTarget.files?.[0] || null; + event.currentTarget.value = ""; + void uploadViewedTaskCover(file); + }} + /> {coverPickerLoading ? (
@@ -9338,12 +9756,28 @@ function TasksPageContent() { && taskComments.length === 0 && taskExternalLinks.length === 0 && taskCoverLinkedOptions.length === 0 ? ( -
- -

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

-
+ ) : null}
) : null} diff --git a/frontend/src/components/procurement/ProcurementCommentAttachment.tsx b/frontend/src/components/procurement/ProcurementCommentAttachment.tsx index 638b3e9a..45e498f6 100644 --- a/frontend/src/components/procurement/ProcurementCommentAttachment.tsx +++ b/frontend/src/components/procurement/ProcurementCommentAttachment.tsx @@ -1,9 +1,12 @@ "use client"; import Image from "next/image"; -import { Download, FileText, Loader2, Play } from "lucide-react"; -import { useEffect, useState } from "react"; +import { Download, Eye, FileText, Loader2, Play } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal"; +import type { MediaPreview } from "@/components/messages/ChatMessageItem"; +import { useNearViewport } from "@/hooks/useNearViewport"; import { apiClient } from "@/lib/api"; import type { ProcurementItemAttachment } from "@/types/api"; @@ -36,58 +39,95 @@ export function ProcurementCommentAttachment({ }) { const image = isImage(attachment); const video = isVideo(attachment); - const [source, setSource] = useState(null); - const [failed, setFailed] = useState(false); + const media = image || video; + const [thumbnailSource, setThumbnailSource] = useState(null); + const [thumbnailFailed, setThumbnailFailed] = useState(false); + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const previewUrlRef = useRef(null); + const { elementRef, isNearViewport } = useNearViewport(); useEffect(() => { - if (!image && !video) return; - + if (!media || !isNearViewport) return; let active = true; let objectUrl: string | null = null; - void apiClient - .downloadProcurementItemAttachment(itemId, attachment) - .then(({ blob }) => { + void apiClient.downloadProcurementItemAttachmentThumbnail(itemId, attachment) + .then((blob) => { if (!active) return; objectUrl = URL.createObjectURL(blob); - setSource(objectUrl); + setThumbnailSource(objectUrl); }) .catch(() => { - if (active) setFailed(true); + if (active) setThumbnailFailed(true); }); return () => { active = false; if (objectUrl) URL.revokeObjectURL(objectUrl); }; - }, [attachment, image, itemId, video]); + }, [attachment, isNearViewport, itemId, media]); - if (image || video) { + useEffect(() => () => { + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current); + }, []); + + const closePreview = useCallback(() => { + setPreview(null); + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + }, []); + + const openPreview = useCallback(async () => { + if (!media || previewLoading) return; + setPreviewLoading(true); + try { + const { blob } = await apiClient.downloadProcurementItemAttachment(itemId, attachment); + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current); + const source = URL.createObjectURL(blob); + previewUrlRef.current = source; + setPreview({ + type: video ? "video" : "image", + src: source, + name: attachment.file_name, + }); + } finally { + setPreviewLoading(false); + } + }, [attachment, itemId, media, previewLoading, video]); + + if (media) { return ( -
-
- {source && image ? ( +
+
+ {thumbnailSource ? ( + + {previewLoading ? : video ? : } + + ) : null} + + {preview ? : null}
); } diff --git a/frontend/src/components/tasks/TaskBoardNavigator.tsx b/frontend/src/components/tasks/TaskBoardNavigator.tsx index 96542aef..1f3fdef3 100644 --- a/frontend/src/components/tasks/TaskBoardNavigator.tsx +++ b/frontend/src/components/tasks/TaskBoardNavigator.tsx @@ -10,6 +10,8 @@ import { Filter, Folder, FolderInput, + Eye, + EyeOff, Kanban, LockKeyhole, Maximize2, @@ -35,6 +37,17 @@ type BoardAccessFilter = "all" | "private" | "shared"; type BoardGroupFilter = "all" | "ungrouped" | number; const NAVIGATOR_COLLAPSED_STORAGE_PREFIX = "tasks.board-navigator-collapsed"; +function boardGroupIds(board: TaskBoardSummary): number[] { + return board.group_ids || (board.group_id ? [board.group_id] : []); +} + +function sameGroupIds(left: number[], right: number[]): boolean { + const normalizedLeft = [...left].sort((first, second) => first - second); + const normalizedRight = [...right].sort((first, second) => first - second); + return normalizedLeft.length === normalizedRight.length + && normalizedLeft.every((groupId, index) => groupId === normalizedRight[index]); +} + type TaskBoardNavigatorProps = { boards: TaskBoardSummary[]; groups: TaskBoardGroup[]; @@ -46,6 +59,7 @@ type TaskBoardNavigatorProps = { onEditGroup: (group: TaskBoardGroup) => void; onDeleteGroup: (group: TaskBoardGroup) => void; onToggleDefaultGroup: (group: TaskBoardGroup) => void; + onToggleHiddenGroup: (group: TaskBoardGroup) => void; onEditBoard: (board: TaskBoardSummary) => void; onDeleteBoard: (board: TaskBoardSummary) => void; onSetDefaultBoard: (board: TaskBoardSummary) => void; @@ -53,7 +67,7 @@ type TaskBoardNavigatorProps = { onToggleBoardNotifications: (board: TaskBoardSummary) => void; onMoveBoard: ( boardId: number, - groupId: number | null, + groupIds: number[], ) => boolean | void | Promise; wideMode: boolean; onWideModeChange: (enabled: boolean) => void; @@ -183,7 +197,7 @@ function BoardRow({ {board.access_scope === "private" ? "Личная" : "Общая"} {groupName ? ( - + {groupName} @@ -265,7 +279,7 @@ function BoardRow({ className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]" > - Переместить в группу + Настроить группы
); })} - {groups.map((group) => { + {visibleGroups.map((group) => { const active = effectiveGroupFilter === group.id; const stats = groupStats.byId.get(group.id) || { total: 0, unread: 0 }; return ( @@ -616,6 +641,9 @@ export default function TaskBoardNavigator({ className="inline-flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-7 text-xs font-medium" > {group.name} + {group.is_shared ? ( + + ) : null} @@ -667,6 +695,18 @@ export default function TaskBoardNavigator({ Редактировать + + {hiddenGroups.length > 0 ? ( + + ) : null}
) : null} @@ -704,7 +758,10 @@ export default function TaskBoardNavigator({ groupNames.get(groupId)) + .filter((groupName): groupName is string => Boolean(groupName)) + .join(", ") || undefined} active={selectedBoardId === board.id} unread={unreadCounts[board.id] || 0} compact={compactNavigator} @@ -733,7 +790,7 @@ export default function TaskBoardNavigator({ }} onMove={() => { setMoveBoardTarget(board); - setMoveGroupId(board.group_id || null); + setMoveGroupIds(boardGroupIds(board)); setMoveGroupQuery(""); setBoardMenuId(null); }} @@ -759,6 +816,51 @@ export default function TaskBoardNavigator({ + setHiddenGroupsOpen(false)} + title="Скрытые группы" + size="sm" + closeOnClickOutside + > +
+ {hiddenGroups.map((group) => ( +
+ +
+
+ + {group.name} + + {group.is_shared ? ( + + ) : null} +
+

+ {group.boards_count} досок +

+
+ +
+ ))} + {hiddenGroups.length === 0 ? ( +

+ Скрытых групп нет +

+ ) : null} +
+
+ setParticipantsBoard(null)} @@ -903,7 +1005,7 @@ export default function TaskBoardNavigator({ void confirmMove()} - disabled={moveSubmitting || (moveBoardTarget?.group_id || null) === moveGroupId} + disabled={moveSubmitting || Boolean( + moveBoardTarget + && sameGroupIds(boardGroupIds(moveBoardTarget), moveGroupIds) + )} className="app-action-primary rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-60" > - {moveSubmitting ? "Перемещение..." : "Переместить"} + {moveSubmitting ? "Сохранение..." : "Сохранить"} )} @@ -940,12 +1045,12 @@ export default function TaskBoardNavigator({

{moveBoardTarget.name}

-

Выберите новую группу

+

Выберите одну или несколько групп

) : null} - {groups.length > 5 ? ( + {visibleGroups.length > 5 ? (