Merge branch 'develop'

This commit is contained in:
lizerk 2026-08-07 17:51:19 +03:00
commit 30a3443db5
33 changed files with 2600 additions and 557 deletions

View File

@ -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

View File

@ -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):
"""Сериализатор для позиций заявки."""

View File

@ -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<attachment_id>\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

View File

@ -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 "Календарное событие"

View File

@ -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<attachment_id>\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":

View File

@ -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,

View File

@ -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

View File

@ -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="Миниатюра",
),
),
]

View File

@ -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)

View File

@ -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

View File

@ -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"]

172
backend/tasks/media.py Normal file
View File

@ -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

View File

@ -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="Миниатюра",
),
),
]

View File

@ -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="Группы",
),
),
]

View File

@ -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",
),
]

View File

@ -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)

View File

@ -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

View File

@ -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
# ==============================================================================

View File

@ -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})

View File

@ -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, {

View File

@ -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;

File diff suppressed because it is too large Load Diff

View File

@ -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<string | null>(null);
const [failed, setFailed] = useState(false);
const media = image || video;
const [thumbnailSource, setThumbnailSource] = useState<string | null>(null);
const [thumbnailFailed, setThumbnailFailed] = useState(false);
const [preview, setPreview] = useState<MediaPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const previewUrlRef = useRef<string | null>(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 (
<div className="app-surface-elevated overflow-hidden rounded-lg border border-[var(--border-subtle)]">
<div className="relative aspect-video min-h-32 bg-[var(--surface-secondary)]">
{source && image ? (
<div ref={elementRef} className="app-surface-elevated overflow-hidden rounded-lg border border-[var(--border-subtle)]">
<button
type="button"
onClick={() => void openPreview()}
disabled={!thumbnailSource || thumbnailFailed || previewLoading}
className="group relative block aspect-video min-h-32 w-full overflow-hidden bg-[var(--surface-secondary)] disabled:cursor-default"
title={`Просмотреть ${attachment.file_name}`}
>
{thumbnailSource ? (
<Image
src={source}
src={thumbnailSource}
alt={attachment.file_name}
fill
unoptimized
sizes="(max-width: 640px) 100vw, 320px"
className="object-contain"
/>
) : source && video ? (
<video
src={source}
controls
preload="metadata"
className="h-full w-full object-contain"
className="object-cover"
/>
) : (
<span className="app-text-muted absolute inset-0 flex items-center justify-center">
{failed ? <Play size={20} /> : <Loader2 size={18} className="animate-spin" />}
{thumbnailFailed ? <FileText size={20} /> : <Loader2 size={18} className="animate-spin" />}
</span>
)}
</div>
{thumbnailSource ? (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/35 text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
{previewLoading ? <Loader2 size={22} className="animate-spin" /> : video ? <Play size={24} fill="currentColor" /> : <Eye size={24} />}
</span>
) : null}
</button>
<button
type="button"
onClick={() => void onDownload(attachment)}
@ -99,6 +139,7 @@ export function ProcurementCommentAttachment({
</span>
<Download size={13} className="app-text-muted shrink-0" />
</button>
{preview ? <ChatMediaPreviewModal preview={preview} onClose={closePreview} /> : null}
</div>
);
}

View File

@ -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<boolean | void>;
wideMode: boolean;
onWideModeChange: (enabled: boolean) => void;
@ -183,7 +197,7 @@ function BoardRow({
{board.access_scope === "private" ? "Личная" : "Общая"}
</span>
{groupName ? (
<span className="inline-flex min-w-0 items-center gap-1" title={`Группа: ${groupName}`}>
<span className="inline-flex min-w-0 items-center gap-1" title={`Группы: ${groupName}`}>
<Folder size={11} className="shrink-0" />
<span className="max-w-32 truncate">{groupName}</span>
</span>
@ -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)]"
>
<FolderInput size={14} className="app-text-muted" />
Переместить в группу
Настроить группы
</button>
<div className="my-1 border-t border-[var(--border-subtle)]" />
<button
@ -293,6 +307,7 @@ export default function TaskBoardNavigator({
onEditGroup,
onDeleteGroup,
onToggleDefaultGroup,
onToggleHiddenGroup,
onEditBoard,
onDeleteBoard,
onSetDefaultBoard,
@ -313,15 +328,24 @@ export default function TaskBoardNavigator({
const [groupMenuId, setGroupMenuId] = useState<number | null>(null);
const [navigatorCollapsed, setNavigatorCollapsed] = useState(false);
const [moveBoardTarget, setMoveBoardTarget] = useState<TaskBoardSummary | null>(null);
const [moveGroupId, setMoveGroupId] = useState<number | null>(null);
const [moveGroupIds, setMoveGroupIds] = useState<number[]>([]);
const [moveGroupQuery, setMoveGroupQuery] = useState("");
const [moveSubmitting, setMoveSubmitting] = useState(false);
const [participantsBoard, setParticipantsBoard] = useState<TaskBoardSummary | null>(null);
const [hiddenGroupsOpen, setHiddenGroupsOpen] = useState(false);
const menusRef = useRef<HTMLDivElement>(null);
const defaultGroupInitializedRef = useRef(false);
const normalizedQuery = query.trim().toLowerCase();
const visibleGroups = useMemo(
() => groups.filter((group) => !group.is_hidden),
[groups],
);
const hiddenGroups = useMemo(
() => groups.filter((group) => group.is_hidden),
[groups],
);
const effectiveGroupFilter = (
typeof groupFilter !== "number" || groups.some((group) => group.id === groupFilter)
typeof groupFilter !== "number" || visibleGroups.some((group) => group.id === groupFilter)
) ? groupFilter : "all";
const filteredBoards = useMemo(() => boards.filter((board) => {
const unread = unreadCounts[board.id] || 0;
@ -332,8 +356,9 @@ export default function TaskBoardNavigator({
return true;
}), [accessFilter, boards, unreadCounts, unreadFilter]);
const visibleBoards = useMemo(() => filteredBoards.filter((board) => {
if (effectiveGroupFilter === "ungrouped" && board.group_id) return false;
if (typeof effectiveGroupFilter === "number" && board.group_id !== effectiveGroupFilter) return false;
const groupIds = boardGroupIds(board);
if (effectiveGroupFilter === "ungrouped" && groupIds.length > 0) return false;
if (typeof effectiveGroupFilter === "number" && !groupIds.includes(effectiveGroupFilter)) return false;
return !normalizedQuery || matchesBoard(board, normalizedQuery);
}), [effectiveGroupFilter, filteredBoards, normalizedQuery]);
const groupStats = useMemo(() => {
@ -346,32 +371,32 @@ export default function TaskBoardNavigator({
});
return {
all: buildStats(filteredBoards),
ungrouped: buildStats(filteredBoards.filter((board) => !board.group_id)),
byId: new Map(groups.map((group) => [
ungrouped: buildStats(filteredBoards.filter((board) => boardGroupIds(board).length === 0)),
byId: new Map(visibleGroups.map((group) => [
group.id,
buildStats(filteredBoards.filter((board) => board.group_id === group.id)),
buildStats(filteredBoards.filter((board) => boardGroupIds(board).includes(group.id))),
])),
};
}, [filteredBoards, groups, unreadCounts]);
}, [filteredBoards, unreadCounts, visibleGroups]);
const groupNames = useMemo(
() => new Map(groups.map((group) => [group.id, group.name])),
[groups],
() => new Map(visibleGroups.map((group) => [group.id, group.name])),
[visibleGroups],
);
const activeFiltersCount = [unreadFilter !== "all", accessFilter !== "all"].filter(Boolean).length;
const compactNavigator = wideMode && navigatorCollapsed;
const filteredMoveGroups = useMemo(() => {
const moveQuery = moveGroupQuery.trim().toLowerCase();
return moveQuery
? groups.filter((group) => group.name.toLowerCase().includes(moveQuery))
: groups;
}, [groups, moveGroupQuery]);
? visibleGroups.filter((group) => group.name.toLowerCase().includes(moveQuery))
: visibleGroups;
}, [moveGroupQuery, visibleGroups]);
useEffect(() => {
if (defaultGroupInitializedRef.current || groups.length === 0) return;
const defaultGroup = groups.find((group) => group.is_default_for_current_user);
if (defaultGroupInitializedRef.current || visibleGroups.length === 0) return;
const defaultGroup = visibleGroups.find((group) => group.is_default_for_current_user);
setGroupFilter(defaultGroup?.id ?? "all");
defaultGroupInitializedRef.current = true;
}, [groups]);
}, [visibleGroups]);
useEffect(() => {
if (!userId) {
@ -403,22 +428,22 @@ export default function TaskBoardNavigator({
const closeMoveModal = () => {
if (moveSubmitting) return;
setMoveBoardTarget(null);
setMoveGroupId(null);
setMoveGroupIds([]);
setMoveGroupQuery("");
};
const confirmMove = async () => {
if (!moveBoardTarget || moveSubmitting) return;
if ((moveBoardTarget.group_id || null) === moveGroupId) {
if (sameGroupIds(boardGroupIds(moveBoardTarget), moveGroupIds)) {
closeMoveModal();
return;
}
setMoveSubmitting(true);
try {
const moved = await onMoveBoard(moveBoardTarget.id, moveGroupId);
const moved = await onMoveBoard(moveBoardTarget.id, moveGroupIds);
if (moved === false) return;
setMoveBoardTarget(null);
setMoveGroupId(null);
setMoveGroupIds([]);
setMoveGroupQuery("");
} finally {
setMoveSubmitting(false);
@ -600,7 +625,7 @@ export default function TaskBoardNavigator({
</button>
);
})}
{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"
>
<span className="max-w-28 truncate">{group.name}</span>
{group.is_shared ? (
<Users size={11} className="app-text-muted shrink-0" />
) : null}
<span className={`app-badge inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-bold ${
active ? "app-pill-count-active" : "app-pill-count"
}`}>
@ -667,6 +695,18 @@ export default function TaskBoardNavigator({
<Pencil size={14} />
Редактировать
</button>
<button
type="button"
onClick={() => {
if (groupFilter === group.id) setGroupFilter("all");
onToggleHiddenGroup(group);
setGroupMenuId(null);
}}
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)]"
>
<EyeOff size={14} />
Скрыть лично
</button>
<button
type="button"
onClick={() => {
@ -693,6 +733,20 @@ export default function TaskBoardNavigator({
>
<Plus size={14} />
</button>
{hiddenGroups.length > 0 ? (
<button
type="button"
onClick={() => setHiddenGroupsOpen(true)}
className="app-pill inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition"
title="Показать скрытые группы"
>
<EyeOff size={12} />
<span>Скрытые</span>
<span className="app-badge app-pill-count px-1.5 py-0.5 text-[10px] font-bold">
{hiddenGroups.length}
</span>
</button>
) : null}
</div>
</div>
) : null}
@ -704,7 +758,10 @@ export default function TaskBoardNavigator({
<BoardRow
key={board.id}
board={board}
groupName={board.group_id ? groupNames.get(board.group_id) : undefined}
groupName={boardGroupIds(board)
.map((groupId) => 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({
</div>
</aside>
<Modal
isOpen={hiddenGroupsOpen}
onClose={() => setHiddenGroupsOpen(false)}
title="Скрытые группы"
size="sm"
closeOnClickOutside
>
<div className="space-y-2 pb-5">
{hiddenGroups.map((group) => (
<div
key={group.id}
className="app-surface-muted flex items-center gap-3 rounded-xl p-3"
>
<Folder size={16} className="app-text-muted shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-medium text-[var(--foreground)]">
{group.name}
</span>
{group.is_shared ? (
<Users size={12} className="app-text-muted shrink-0" />
) : null}
</div>
<p className="app-text-muted mt-0.5 text-xs">
{group.boards_count} досок
</p>
</div>
<button
type="button"
onClick={() => onToggleHiddenGroup(group)}
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium"
>
<Eye size={14} />
Показать
</button>
</div>
))}
{hiddenGroups.length === 0 ? (
<p className="app-text-muted py-6 text-center text-sm">
Скрытых групп нет
</p>
) : null}
</div>
</Modal>
<Modal
isOpen={Boolean(participantsBoard)}
onClose={() => setParticipantsBoard(null)}
@ -903,7 +1005,7 @@ export default function TaskBoardNavigator({
<Modal
isOpen={Boolean(moveBoardTarget)}
onClose={closeMoveModal}
title="Переместить доску"
title="Группы доски"
size="sm"
closeOnClickOutside
closeOnEsc={!moveSubmitting}
@ -920,10 +1022,13 @@ export default function TaskBoardNavigator({
<button
type="button"
onClick={() => 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 ? "Сохранение..." : "Сохранить"}
</button>
</div>
)}
@ -940,12 +1045,12 @@ export default function TaskBoardNavigator({
<p className="truncate text-sm font-semibold text-[var(--foreground)]">
{moveBoardTarget.name}
</p>
<p className="app-text-muted mt-0.5 text-xs">Выберите новую группу</p>
<p className="app-text-muted mt-0.5 text-xs">Выберите одну или несколько групп</p>
</div>
</div>
) : null}
{groups.length > 5 ? (
{visibleGroups.length > 5 ? (
<label className="relative block">
<Search
size={15}
@ -964,26 +1069,30 @@ export default function TaskBoardNavigator({
<div className="tasks-column-scroll max-h-[45vh] space-y-1 overflow-y-auto pr-1">
<button
type="button"
onClick={() => setMoveGroupId(null)}
onClick={() => setMoveGroupIds([])}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition ${
moveGroupId === null ? "app-selected" : "hover:bg-[var(--surface-secondary)]"
moveGroupIds.length === 0 ? "app-selected" : "hover:bg-[var(--surface-secondary)]"
}`}
>
<Check size={15} className={moveGroupId === null ? "app-accent-text" : "invisible"} />
<Check size={15} className={moveGroupIds.length === 0 ? "app-accent-text" : "invisible"} />
<span className="min-w-0 flex-1 truncate">Без группы</span>
</button>
{filteredMoveGroups.map((group) => (
<button
key={group.id}
type="button"
onClick={() => setMoveGroupId(group.id)}
onClick={() => setMoveGroupIds((current) => (
current.includes(group.id)
? current.filter((groupId) => groupId !== group.id)
: [...current, group.id]
))}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition ${
moveGroupId === group.id ? "app-selected" : "hover:bg-[var(--surface-secondary)]"
moveGroupIds.includes(group.id) ? "app-selected" : "hover:bg-[var(--surface-secondary)]"
}`}
>
<Check
size={15}
className={moveGroupId === group.id ? "app-accent-text" : "invisible"}
className={moveGroupIds.includes(group.id) ? "app-accent-text" : "invisible"}
/>
<span className="min-w-0 flex-1 truncate">{group.name}</span>
<span className="app-badge rounded-full px-2 py-0.5 text-[10px]">

View File

@ -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 { TaskAttachment } from "@/types/api";
@ -36,58 +39,95 @@ export default function TaskCommentAttachment({
}) {
const image = isImage(attachment);
const video = isVideo(attachment);
const [source, setSource] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const media = image || video;
const [thumbnailSource, setThumbnailSource] = useState<string | null>(null);
const [thumbnailFailed, setThumbnailFailed] = useState(false);
const [preview, setPreview] = useState<MediaPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const previewUrlRef = useRef<string | null>(null);
const { elementRef, isNearViewport } = useNearViewport();
useEffect(() => {
if (!image && !video) return;
if (!media || !isNearViewport) return;
let active = true;
let objectUrl: string | null = null;
void apiClient
.downloadTaskAttachment(taskId, attachment)
.then(({ blob }) => {
void apiClient.downloadTaskAttachmentThumbnail(taskId, 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, taskId, video]);
}, [attachment, isNearViewport, media, taskId]);
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.downloadTaskAttachment(taskId, 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, media, previewLoading, taskId, video]);
if (media) {
return (
<div className="app-surface-elevated overflow-hidden rounded-lg border border-[var(--border-subtle)]">
<div className="relative aspect-video min-h-32 bg-[var(--surface-secondary)]">
{source && image ? (
<div ref={elementRef} className="app-surface-elevated overflow-hidden rounded-lg border border-[var(--border-subtle)]">
<button
type="button"
onClick={() => void openPreview()}
disabled={!thumbnailSource || thumbnailFailed || previewLoading}
className="group relative block aspect-video min-h-32 w-full overflow-hidden bg-[var(--surface-secondary)] disabled:cursor-default"
title={`Просмотреть ${attachment.file_name}`}
>
{thumbnailSource ? (
<Image
src={source}
src={thumbnailSource}
alt={attachment.file_name}
fill
unoptimized
sizes="(max-width: 640px) 100vw, 320px"
className="object-contain"
/>
) : source && video ? (
<video
src={source}
controls
preload="metadata"
className="h-full w-full object-contain"
className="object-cover"
/>
) : (
<span className="app-text-muted absolute inset-0 flex items-center justify-center">
{failed ? <Play size={20} /> : <Loader2 size={18} className="animate-spin" />}
{thumbnailFailed ? <FileText size={20} /> : <Loader2 size={18} className="animate-spin" />}
</span>
)}
</div>
{thumbnailSource ? (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/35 text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
{previewLoading ? <Loader2 size={22} className="animate-spin" /> : video ? <Play size={24} fill="currentColor" /> : <Eye size={24} />}
</span>
) : null}
</button>
<button
type="button"
onClick={() => void onDownload(attachment)}
@ -99,6 +139,7 @@ export default function TaskCommentAttachment({
</span>
<Download size={13} className="app-text-muted shrink-0" />
</button>
{preview ? <ChatMediaPreviewModal preview={preview} onClose={closePreview} /> : null}
</div>
);
}

View File

@ -11,7 +11,7 @@ import {
MessageSquare,
Play,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { apiClient } from "@/lib/api";
import { displayUserName } from "@/lib/shared";
@ -27,6 +27,41 @@ function isVideo(attachment: TaskAttachment): boolean {
return /\.(m4v|mkv|mov|mp4|mpeg|mpg|ogv|webm)$/i.test(attachment.file_name);
}
const coverVisibilityCallbacks = new Map<Element, () => void>();
let coverVisibilityObserver: IntersectionObserver | null = null;
function observeCoverVisibility(element: Element, onVisible: () => void) {
if (typeof IntersectionObserver === "undefined") {
onVisible();
return () => undefined;
}
if (!coverVisibilityObserver) {
coverVisibilityObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const callback = coverVisibilityCallbacks.get(entry.target);
if (!callback) return;
coverVisibilityCallbacks.delete(entry.target);
coverVisibilityObserver?.unobserve(entry.target);
callback();
});
}, {
rootMargin: "900px 700px",
threshold: 0.01,
});
}
coverVisibilityCallbacks.set(element, onVisible);
coverVisibilityObserver.observe(element);
return () => {
coverVisibilityCallbacks.delete(element);
coverVisibilityObserver?.unobserve(element);
if (coverVisibilityCallbacks.size === 0) {
coverVisibilityObserver?.disconnect();
coverVisibilityObserver = null;
}
};
}
function AuthenticatedCoverMedia({
taskId,
attachment,
@ -35,9 +70,11 @@ function AuthenticatedCoverMedia({
attachment: TaskAttachment;
}) {
const [source, setSource] = useState<string | null>(null);
const [sourceIsOriginal, setSourceIsOriginal] = useState(false);
const [failed, setFailed] = useState(false);
const video = isVideo(attachment);
const attachmentId = attachment.id;
const thumbnailUrl = attachment.thumbnail_url;
const downloadUrl = attachment.download_url;
const fileName = attachment.file_name;
@ -46,14 +83,23 @@ function AuthenticatedCoverMedia({
let objectUrl: string | null = null;
void apiClient
.downloadTaskAttachment(taskId, {
.downloadTaskAttachmentThumbnail(taskId, {
id: attachmentId,
download_url: downloadUrl,
file_name: fileName,
thumbnail_url: thumbnailUrl,
})
.then(({ blob }) => {
.then((blob) => ({ blob, original: false }))
.catch(async () => {
const { blob } = await apiClient.downloadTaskAttachment(taskId, {
id: attachmentId,
download_url: downloadUrl,
file_name: fileName,
});
return { blob, original: true };
})
.then(({ blob, original }) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setSourceIsOriginal(original);
setSource(objectUrl);
})
.catch(() => {
@ -64,39 +110,37 @@ function AuthenticatedCoverMedia({
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachmentId, downloadUrl, fileName, taskId]);
}, [attachmentId, downloadUrl, fileName, taskId, thumbnailUrl]);
return (
<div className="absolute inset-0 overflow-hidden bg-[var(--surface-secondary)]">
{source && video ? (
{source ? (
<>
<video
src={source}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover"
onLoadedMetadata={(event) => {
const media = event.currentTarget;
if (Number.isFinite(media.duration) && media.duration > 0.2) {
media.currentTime = Math.min(0.25, media.duration / 10);
}
}}
/>
<span className="app-surface-elevated pointer-events-none absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center rounded-full border border-[var(--border-subtle)]">
<Play size={13} className="translate-x-px" />
</span>
{video && sourceIsOriginal ? (
<video
src={source}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover"
/>
) : (
<Image
src={source}
alt=""
fill
unoptimized
sizes="272px"
className="object-cover"
draggable={false}
/>
)}
{video ? (
<span className="app-surface-elevated pointer-events-none absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center rounded-full border border-[var(--border-subtle)]">
<Play size={13} className="translate-x-px" />
</span>
) : null}
</>
) : source ? (
<Image
src={source}
alt=""
fill
unoptimized
sizes="272px"
className="object-cover"
draggable={false}
/>
) : (
<span className="app-text-muted absolute inset-0 flex items-center justify-center">
{failed ? <ImagePlus size={20} /> : <Loader2 size={18} className="animate-spin" />}
@ -106,6 +150,38 @@ function AuthenticatedCoverMedia({
);
}
function LazyAuthenticatedCoverMedia({
taskId,
attachment,
}: {
taskId: number;
attachment: TaskAttachment;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [shouldLoad, setShouldLoad] = useState(false);
useEffect(() => {
const container = containerRef.current;
if (!container || shouldLoad) return;
return observeCoverVisibility(container, () => setShouldLoad(true));
}, [shouldLoad]);
return (
<div
ref={containerRef}
className="absolute inset-0 overflow-hidden bg-[var(--surface-secondary)]"
>
{shouldLoad ? (
<AuthenticatedCoverMedia
key={attachment.id}
taskId={taskId}
attachment={attachment}
/>
) : null}
</div>
);
}
export default function TaskCoverPreview({
taskId,
cover,
@ -126,7 +202,7 @@ export default function TaskCoverPreview({
if (media) {
return (
<div className={`relative overflow-hidden bg-[var(--surface-secondary)] ${className}`}>
<AuthenticatedCoverMedia taskId={taskId} attachment={media} />
<LazyAuthenticatedCoverMedia taskId={taskId} attachment={media} />
</div>
);
}

View File

@ -0,0 +1,29 @@
"use client";
import { useEffect, useRef, useState } from "react";
export function useNearViewport(rootMargin = "240px") {
const elementRef = useRef<HTMLDivElement | null>(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 };
}

View File

@ -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;

View File

@ -118,6 +118,15 @@ export function createProcurementApi(request: RequestFn, requestRaw: RawRequestF
filename: parseDownloadFilename(response, attachment.file_name),
};
},
downloadProcurementItemAttachmentThumbnail: async (
itemId: number,
attachment: ProcurementItemAttachment,
): Promise<Blob> => {
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<void> =>
request(`/api/v1/procurement/items/${itemId}/attachments/${attachmentId}/`, { method: 'DELETE' }),
// Suppliers

View File

@ -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<TaskBoard> =>
request(`/api/v1/tasks/boards/${id}/set-default/`, { method: "POST" }),
toggleTaskBoardPin: (id: number): Promise<{ ok: boolean; is_pinned: boolean }> =>
@ -101,14 +109,14 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
TaskBoardGroup[] | { results: TaskBoardGroup[] }
> =>
request("/api/v1/tasks/board-groups/"),
createTaskBoardGroup: (data: { name: string }): Promise<TaskBoardGroup> =>
createTaskBoardGroup: (data: { name: string; is_shared?: boolean }): Promise<TaskBoardGroup> =>
request("/api/v1/tasks/board-groups/", {
method: "POST",
body: JSON.stringify(data),
}),
updateTaskBoardGroup: (
id: number,
data: { name: string },
data: { name: string; is_shared?: boolean },
): Promise<TaskBoardGroup> =>
request(`/api/v1/tasks/board-groups/${id}/`, {
method: "PATCH",
@ -125,6 +133,12 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
request(`/api/v1/tasks/board-groups/${id}/set-default/`, {
method: "POST",
}),
toggleTaskBoardGroupHidden: (
id: number,
): Promise<{ group: TaskBoardGroup; is_hidden: boolean }> =>
request(`/api/v1/tasks/board-groups/${id}/toggle-hidden/`, {
method: "POST",
}),
markTaskBoardNotificationsRead: (
id: number,
): Promise<{ status: string; count: number; notification_ids: number[] }> =>
@ -260,6 +274,15 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
filename: parseDownloadFilename(response, attachment.file_name),
};
},
downloadTaskAttachmentThumbnail: async (
taskId: number,
attachment: Pick<TaskAttachment, "id" | "thumbnail_url">,
): Promise<Blob> => {
const response = await requestRaw(
attachment.thumbnail_url || `/api/v1/tasks/${taskId}/attachments/${attachment.id}/thumbnail/`,
);
return response.blob();
},
deleteTaskAttachment: (taskId: number, attachmentId: number): Promise<void> =>
request(`/api/v1/tasks/${taskId}/attachments/${attachmentId}/`, {
method: "DELETE",

View File

@ -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({

View File

@ -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,
};

View File

@ -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;
@ -398,8 +400,11 @@ export interface TaskBoardGroup {
id: number;
name: string;
position: number;
is_shared: boolean;
boards_count: number;
is_default_for_current_user: boolean;
is_hidden: boolean;
is_owner: boolean;
created_at: string;
updated_at: string;
}
@ -640,6 +645,7 @@ export interface TaskAttachment {
uploaded_by?: User;
created_at: string;
download_url: string;
thumbnail_url?: string | null;
}
export interface TaskChecklistItem {
@ -1279,6 +1285,7 @@ export interface ProcurementItemAttachment {
uploaded_by_name?: string;
created_at: string;
download_url?: string;
thumbnail_url?: string | null;
}
export interface ProcurementItem {