Merge branch 'develop'
This commit is contained in:
commit
30a3443db5
@ -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
|
||||
|
||||
@ -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):
|
||||
"""Сериализатор для позиций заявки."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 "Календарное событие"
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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,
|
||||
|
||||
43
backend/procurement/media.py
Normal file
43
backend/procurement/media.py
Normal 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
|
||||
@ -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="Миниатюра",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
172
backend/tasks/media.py
Normal 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
|
||||
20
backend/tasks/migrations/0035_taskattachment_thumbnail.py
Normal file
20
backend/tasks/migrations/0035_taskattachment_thumbnail.py
Normal 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="Миниатюра",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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="Группы",
|
||||
),
|
||||
),
|
||||
]
|
||||
95
backend/tasks/migrations/0037_shared_task_board_groups.py
Normal file
95
backend/tasks/migrations/0037_shared_task_board_groups.py
Normal 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",
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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]">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
29
frontend/src/hooks/useNearViewport.ts
Normal file
29
frontend/src/hooks/useNearViewport.ts
Normal 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 };
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user