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 79e54e57..ef9f43ce 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -807,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, @@ -845,6 +841,7 @@ class TaskBoardSerializer(serializers.ModelSerializer): "labels", "tasks_count", "is_default_for_current_user", + "group_ids", "group_id", "is_pinned", "notifications_enabled", @@ -861,6 +858,30 @@ 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): + placements = getattr(obj, "current_user_placements", None) + if placements is not None: + placement = placements[0] if placements else None + else: + request = self.context.get("request") + user = getattr(request, "user", None) + if not user or not user.is_authenticated: + return [] + placement = ( + TaskBoardPlacement.objects.filter(user=user, board=obj) + .prefetch_related("groups") + .first() + ) + if not placement: + return [] + return sorted(group.id for group in placement.groups.all()) + + @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") @@ -970,6 +991,7 @@ class TaskBoardSummarySerializer(TaskBoardSerializer): "open_tasks_count", "completed_tasks_count", "is_default_for_current_user", + "group_ids", "group_id", "is_pinned", "notifications_enabled", diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index d48f2f48..d3ce442a 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -161,16 +161,20 @@ def create_task_activity( def normalize_column_positions(column, row=None): - task_ids = list( + tasks = list( column.tasks.filter(row=row) .order_by("position", "-created_at", "id") - .values_list( - "id", - flat=True, - ) + .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): @@ -328,12 +332,18 @@ 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, + ).prefetch_related("groups"), + to_attr="current_user_placements", ), + ) + .annotate( current_user_is_pinned=Coalesce( Subquery( current_placement.values("is_pinned")[:1], @@ -607,21 +617,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) @@ -635,12 +631,65 @@ class TaskBoardViewSet(viewsets.ModelViewSet): 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, + ) + groups = list( + TaskBoardGroup.objects.filter( + user=request.user, + id__in=group_ids, + ).order_by("id") ) - return Response({"board": board.id, "group_id": group.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(): + placement = TaskBoardPlacement.objects.select_for_update().filter( + user=self.request.user, + board=board, + ).first() + if placement is None and groups: + placement = TaskBoardPlacement.objects.create( + user=self.request.user, + board=board, + ) + if placement: + placement.groups.set(groups) + if ( + not groups + and not placement.is_pinned + and placement.notifications_enabled + and not placement.view_state + ): + placement.delete() + else: + placement.save(update_fields=["updated_at"]) + group_ids = sorted(group.id for group in groups) + 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): @@ -648,14 +697,13 @@ 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 not placement.groups.exists() and placement.notifications_enabled and not placement.view_state ): @@ -670,13 +718,12 @@ 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.groups.exists() and not placement.is_pinned and not placement.view_state ): @@ -739,7 +786,6 @@ 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() @@ -757,7 +803,7 @@ class TaskBoardViewSet(viewsets.ModelViewSet): placement.view_state = state if ( not state - and placement.group_id is None + and not placement.groups.exists() and not placement.is_pinned and placement.notifications_enabled ): @@ -826,7 +872,7 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet): instance.delete() TaskBoardPlacement.objects.filter( user=user, - group__isnull=True, + groups__isnull=True, is_pinned=False, notifications_enabled=True, view_state={}, @@ -1341,6 +1387,7 @@ 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) @@ -1357,9 +1404,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 = { @@ -1390,7 +1442,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 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/admin.py b/backend/tasks/admin.py index 1a2ce054..160d49ef 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -52,17 +52,21 @@ class TaskBoardGroupAdmin(admin.ModelAdmin): class TaskBoardPlacementAdmin(admin.ModelAdmin): list_display = [ "board", - "group", + "group_names", "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", "groups"] + search_fields = ["board__name", "groups__name", "user__email"] + autocomplete_fields = ["board", "groups", "user"] readonly_fields = ["view_state"] + @admin.display(description="Группы") + def group_names(self, obj): + return ", ".join(obj.groups.values_list("name", flat=True)) or "—" + @admin.register(TaskColumn) class TaskColumnAdmin(admin.ModelAdmin): diff --git a/backend/tasks/media.py b/backend/tasks/media.py index 96473ca1..dbb12287 100644 --- a/backend/tasks/media.py +++ b/backend/tasks/media.py @@ -37,7 +37,7 @@ VIDEO_EXTENSIONS = { } -def task_attachment_media_kind(attachment: TaskAttachment) -> str | None: +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: @@ -47,6 +47,10 @@ def task_attachment_media_kind(attachment: TaskAttachment) -> str | None: 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) @@ -137,19 +141,23 @@ def _render_video_thumbnail(attachment: TaskAttachment) -> bytes: 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 - media_kind = task_attachment_media_kind(attachment) - if not media_kind: + if not task_attachment_media_kind(attachment): return False try: - thumbnail = ( - _render_image_thumbnail(attachment) - if media_kind == "image" - else _render_video_thumbnail(attachment) - ) + thumbnail = render_attachment_thumbnail(attachment) attachment.thumbnail.save( f"task-{attachment.task_id}-attachment-{attachment.id}.webp", ContentFile(thumbnail), 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/models.py b/backend/tasks/models.py index f2040718..17d71cd8 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -146,13 +146,11 @@ class TaskBoardPlacement(models.Model): related_name="user_placements", verbose_name="Доска", ) - group = models.ForeignKey( + groups = models.ManyToManyField( TaskBoardGroup, - on_delete=models.SET_NULL, - null=True, blank=True, related_name="board_placements", - verbose_name="Группа", + verbose_name="Группы", ) is_pinned = models.BooleanField("Закреплена", default=False) pinned_at = models.DateTimeField("Закреплена в", null=True, blank=True) @@ -180,7 +178,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): 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 12387686..865a266f 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -45,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 @@ -316,25 +317,44 @@ 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]) + placement = TaskBoardPlacement.objects.get(user=user, board=board) + assert set(placement.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( @@ -347,8 +367,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}, @@ -362,13 +390,25 @@ 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() + placement.refresh_from_db() + assert list(placement.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_user_can_toggle_personal_default_board_group(api_client, user): @@ -456,7 +496,7 @@ 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 not placement.groups.exists() assert placement.is_pinned is True assert placement.notifications_enabled is False @@ -482,7 +522,7 @@ 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 not placement.groups.exists() assert placement.is_pinned is True assert placement.notifications_enabled is False @@ -1056,18 +1096,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 @@ -1081,6 +1123,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( @@ -1245,6 +1322,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) 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 25691e4e..6fa212d0 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -70,9 +70,11 @@ import { memo, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, + type CSSProperties, type ReactNode, type RefObject, } from "react"; @@ -442,6 +444,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); @@ -613,20 +634,7 @@ function getMessageCreatedLabel(message?: Message | null) { return message.created || (message.created_at ? formatDate(message.created_at) : ""); } -const TaskCardView = memo(function TaskCardView({ - task, - onOpen, - onEdit, - onDelete, - currentUserId, - claiming, - onClaim, - completing, - onComplete, - menuOpen, - menuRef, - onToggleTaskMenu, -}: { +type TaskCardViewProps = { task: TaskCard; onOpen: (task: TaskCard) => void; onEdit: (task: TaskCard) => void; @@ -637,14 +645,43 @@ const TaskCardView = memo(function TaskCardView({ completing: boolean; onComplete: (task: TaskCard) => void; menuOpen: boolean; - menuRef: RefObject; - onToggleTaskMenu: (taskId: number) => 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; @@ -654,11 +691,16 @@ const TaskCardView = memo(function TaskCardView({
onOpen(task)} + onPointerEnter={onPointerEnter} + onPointerLeave={onPointerLeave} + onPointerDownCapture={onPointerDownCapture} + onPointerUpCapture={onPointerUpCapture} + onPointerCancelCapture={onPointerCancelCapture} {...attributes} {...listeners} > @@ -683,14 +725,13 @@ const TaskCardView = memo(function TaskCardView({
event.stopPropagation()} onClick={(event) => event.stopPropagation()} > - {menuOpen ? ( -
- - -
- ) : null}
{priority.urgencyLabel} срочность @@ -825,6 +846,86 @@ const TaskCardView = memo(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); @@ -1092,6 +1193,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; @@ -1103,8 +1279,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; }; @@ -1119,7 +1294,6 @@ function TaskColumnCards({ completingTaskId, onCompleteTask, openMenuTaskId, - menuRef, onToggleTaskMenu, quickTaskComposer, }: TaskColumnCardsProps) { @@ -1140,7 +1314,6 @@ function TaskColumnCards({ completing={completingTaskId === task.id} onComplete={onCompleteTask} menuOpen={openMenuTaskId === task.id} - menuRef={menuRef} onToggleTaskMenu={onToggleTaskMenu} /> )) @@ -1167,7 +1340,6 @@ function BoardColumn({ completingTaskId, onCompleteTask, openMenuTaskId, - menuRef, onToggleTaskMenu, columnMenuOpen, columnMenuRef, @@ -1198,8 +1370,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; @@ -1393,7 +1564,6 @@ function BoardColumn({ completingTaskId={completingTaskId} onCompleteTask={onCompleteTask} openMenuTaskId={openMenuTaskId} - menuRef={menuRef} onToggleTaskMenu={onToggleTaskMenu} quickTaskComposer={quickTaskComposer} /> @@ -3646,6 +3816,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); @@ -4159,6 +4330,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), }); @@ -4312,8 +4491,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); } @@ -4443,6 +4622,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], @@ -4816,8 +5000,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) => { @@ -5333,7 +5524,9 @@ function TasksPageContent() { const deleteBoardGroup = useCallback(async (group: TaskBoardGroup) => { if (saving) return; - if (!window.confirm(`Удалить группу «${group.name}»? Доски останутся в разделе «Без группы».`)) { + if (!window.confirm( + `Удалить группу «${group.name}»? Она будет убрана у досок, остальные группы сохранятся.`, + )) { return; } setSaving(true); @@ -5367,27 +5560,48 @@ 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; + 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_id: groupId } : item + item.id === boardId + ? { ...item, group_ids: nextGroupIds, group_id: nextGroupId } + : item ))); - setBoard((current) => current?.id === boardId ? { ...current, group_id: groupId } : current); + setBoard((current) => current?.id === boardId + ? { ...current, group_ids: nextGroupIds, group_id: nextGroupId } + : current); setSaving(true); setError(null); try { - await apiClient.moveTaskBoardToGroup(boardId, groupId); + 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); @@ -6728,16 +6942,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 ( @@ -6933,14 +7159,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 ? ( <> @@ -7101,7 +7343,7 @@ function TasksPageContent() { 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} @@ -7400,7 +7642,6 @@ function TasksPageContent() { completingTaskId={completingTaskId} onCompleteTask={completeTask} openMenuTaskId={cardMenuTaskId} - menuRef={cardMenuRef} onToggleTaskMenu={toggleCardTaskMenu} columnMenuOpen={columnMenuId === column.id} columnMenuRef={columnMenuRef} @@ -7460,7 +7701,6 @@ function TasksPageContent() { completingTaskId={completingTaskId} onCompleteTask={completeTask} openMenuTaskId={cardMenuTaskId} - menuRef={cardMenuRef} onToggleTaskMenu={toggleCardTaskMenu} linkedColumnHighlight={activeColumnDragId === cellColumn.id} synchronizedColumnDrag={activeColumnDragId !== null} @@ -7574,6 +7814,32 @@ function TasksPageContent() { {activeDragTask ? : null} + + {cardMenuTask ? ( + + + + + ) : 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..24396ba4 100644 --- a/frontend/src/components/tasks/TaskBoardNavigator.tsx +++ b/frontend/src/components/tasks/TaskBoardNavigator.tsx @@ -35,6 +35,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[]; @@ -53,7 +64,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 +194,7 @@ function BoardRow({ {board.access_scope === "private" ? "Личная" : "Общая"} {groupName ? ( - + {groupName} @@ -265,7 +276,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)]" > - Переместить в группу + Настроить группы
)} @@ -940,7 +958,7 @@ export default function TaskBoardNavigator({

{moveBoardTarget.name}

-

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

+

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

) : null} @@ -964,26 +982,30 @@ export default function TaskBoardNavigator({
{filteredMoveGroups.map((group) => (
+ {thumbnailSource ? ( + + {previewLoading ? : video ? : } + + ) : null} + + {preview ? : null} ); } diff --git a/frontend/src/hooks/useNearViewport.ts b/frontend/src/hooks/useNearViewport.ts new file mode 100644 index 00000000..074481a6 --- /dev/null +++ b/frontend/src/hooks/useNearViewport.ts @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + + +export function useNearViewport(rootMargin = "240px") { + const elementRef = useRef(null); + const [isNearViewport, setIsNearViewport] = useState(false); + + useEffect(() => { + if (isNearViewport) return; + const element = elementRef.current; + if (!element) return; + if (typeof IntersectionObserver === "undefined") { + const fallbackId = window.setTimeout(() => setIsNearViewport(true), 0); + return () => window.clearTimeout(fallbackId); + } + + const observer = new IntersectionObserver((entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + setIsNearViewport(true); + observer.disconnect(); + }, { rootMargin }); + observer.observe(element); + return () => observer.disconnect(); + }, [isNearViewport, rootMargin]); + + return { elementRef, isNearViewport }; +} diff --git a/frontend/src/hooks/useTaskBoardRealtime.ts b/frontend/src/hooks/useTaskBoardRealtime.ts index 9c056c9e..d043d310 100644 --- a/frontend/src/hooks/useTaskBoardRealtime.ts +++ b/frontend/src/hooks/useTaskBoardRealtime.ts @@ -11,6 +11,12 @@ type TaskBoardRealtimeOptions = { onChecklistChange: (taskId: number) => void; onCommentsChange: (taskId: number) => void; onActivityChange: (taskId: number) => void; + onTaskMove: ( + taskId: number, + columnId: number, + rowId: number | null, + position: number | null, + ) => void; onBoardChange: (boardId: number) => void; debounceMs?: number; }; @@ -47,6 +53,21 @@ export function useTaskBoardRealtime(options: TaskBoardRealtimeOptions): void { } } + if ( + update.model === "task" + && update.event === "moved" + && update.taskId + && update.columnId + ) { + current.onTaskMove( + update.taskId, + update.columnId, + update.rowId, + update.position, + ); + return; + } + if (syncTimerRef.current) clearTimeout(syncTimerRef.current); syncTimerRef.current = setTimeout(() => { syncTimerRef.current = null; diff --git a/frontend/src/lib/api/procurement.ts b/frontend/src/lib/api/procurement.ts index 90a7adab..3a222fff 100644 --- a/frontend/src/lib/api/procurement.ts +++ b/frontend/src/lib/api/procurement.ts @@ -118,6 +118,15 @@ export function createProcurementApi(request: RequestFn, requestRaw: RawRequestF filename: parseDownloadFilename(response, attachment.file_name), }; }, + downloadProcurementItemAttachmentThumbnail: async ( + itemId: number, + attachment: ProcurementItemAttachment, + ): Promise => { + const response = await requestRaw( + attachment.thumbnail_url || `/api/v1/procurement/items/${itemId}/attachments/${attachment.id}/thumbnail/`, + ); + return response.blob(); + }, deleteProcurementItemAttachment: (itemId: number, attachmentId: number): Promise => request(`/api/v1/procurement/items/${itemId}/attachments/${attachmentId}/`, { method: 'DELETE' }), // Suppliers diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts index 164119e3..c5afa0a4 100644 --- a/frontend/src/lib/api/tasks.ts +++ b/frontend/src/lib/api/tasks.ts @@ -72,11 +72,19 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) { moveTaskBoardToGroup: ( id: number, group: number | null, - ): Promise<{ board: number; group_id: number | null }> => + ): Promise<{ board: number; group_ids: number[]; group_id: number | null }> => request(`/api/v1/tasks/boards/${id}/move-to-group/`, { method: "POST", body: JSON.stringify({ group }), }), + setTaskBoardGroups: ( + id: number, + groups: number[], + ): Promise<{ board: number; group_ids: number[]; group_id: number | null }> => + request(`/api/v1/tasks/boards/${id}/groups/`, { + method: "POST", + body: JSON.stringify({ groups }), + }), setDefaultTaskBoard: (id: number): Promise => request(`/api/v1/tasks/boards/${id}/set-default/`, { method: "POST" }), toggleTaskBoardPin: (id: number): Promise<{ ok: boolean; is_pinned: boolean }> => diff --git a/frontend/src/lib/task-board-events.test.js b/frontend/src/lib/task-board-events.test.js index 35d51363..dd64a194 100644 --- a/frontend/src/lib/task-board-events.test.js +++ b/frontend/src/lib/task-board-events.test.js @@ -15,6 +15,9 @@ test("task board event parser normalizes task updates", () => { }), { boardId: 7, taskId: 42, + columnId: null, + rowId: null, + position: null, model: "task", event: "updated", }); @@ -33,11 +36,37 @@ test("task board event parser keeps explicit parent task ids", () => { }), { boardId: 3, taskId: 12, + columnId: null, + rowId: null, + position: null, model: "checklist_item", event: "updated", }); }); +test("task board event parser keeps lightweight move coordinates", () => { + assert.deepEqual(parseTaskBoardSocketUpdate({ + type: "task_board_update", + data: { + board_id: 3, + model: "task", + object_id: 12, + event: "moved", + column_id: "8", + row_id: null, + position: "4000", + }, + }), { + boardId: 3, + taskId: 12, + columnId: 8, + rowId: null, + position: 4000, + model: "task", + event: "moved", + }); +}); + test("task board event parser rejects unrelated and invalid events", () => { assert.equal(parseTaskBoardSocketUpdate({ type: "notification" }), null); assert.equal(parseTaskBoardSocketUpdate({ diff --git a/frontend/src/lib/tasks/taskBoardEvents.ts b/frontend/src/lib/tasks/taskBoardEvents.ts index 92070d4b..d86da6ce 100644 --- a/frontend/src/lib/tasks/taskBoardEvents.ts +++ b/frontend/src/lib/tasks/taskBoardEvents.ts @@ -1,6 +1,9 @@ export type TaskBoardSocketUpdate = { boardId: number; taskId: number | null; + columnId: number | null; + rowId: number | null; + position: number | null; event: string; model: string; }; @@ -13,6 +16,9 @@ type TaskBoardSocketEvent = { model?: unknown; object_id?: unknown; task_id?: unknown; + column_id?: unknown; + row_id?: unknown; + position?: unknown; }; }; @@ -34,10 +40,20 @@ export function parseTaskBoardSocketUpdate( const rawTaskId = socketEvent.data?.task_id ?? (model === "task" ? socketEvent.data?.object_id : undefined); const taskId = Number(rawTaskId); + const columnId = Number(socketEvent.data?.column_id); + const rawRowId = socketEvent.data?.row_id; + const rowId = Number(rawRowId); + const position = Number(socketEvent.data?.position); return { boardId, taskId: Number.isFinite(taskId) && taskId > 0 ? taskId : null, + columnId: Number.isFinite(columnId) && columnId > 0 ? columnId : null, + rowId: rawRowId !== null && rawRowId !== undefined + && Number.isFinite(rowId) && rowId > 0 + ? rowId + : null, + position: Number.isFinite(position) ? position : null, event: eventName, model, }; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 272935fc..d6e392c0 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -366,6 +366,7 @@ export interface TaskBoard { labels: TaskLabel[]; tasks_count?: number; is_default_for_current_user?: boolean; + group_ids?: number[]; group_id?: number | null; is_pinned?: boolean; notifications_enabled?: boolean; @@ -387,6 +388,7 @@ export interface TaskBoardSummary { open_tasks_count: number; completed_tasks_count: number; is_default_for_current_user?: boolean; + group_ids?: number[]; group_id?: number | null; is_pinned?: boolean; notifications_enabled?: boolean; @@ -1280,6 +1282,7 @@ export interface ProcurementItemAttachment { uploaded_by_name?: string; created_at: string; download_url?: string; + thumbnail_url?: string | null; } export interface ProcurementItem {