perf(tasks): optimize large boards and media covers
This commit is contained in:
parent
73aa685b1d
commit
df1deece23
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -926,11 +933,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())
|
||||
@ -998,7 +1007,9 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer):
|
||||
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())
|
||||
@ -1165,6 +1176,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 +1190,7 @@ class TaskAttachmentSerializer(serializers.ModelSerializer):
|
||||
"uploaded_by",
|
||||
"created_at",
|
||||
"download_url",
|
||||
"thumbnail_url",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
@ -1190,6 +1203,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 +1309,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 +1363,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 +1374,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 +1441,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 +1460,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 +1894,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 "Календарное событие"
|
||||
|
||||
@ -51,6 +51,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 +67,7 @@ from tasks.models import (
|
||||
TaskBoardPlacement,
|
||||
TaskColumn,
|
||||
TaskColumnRow,
|
||||
TaskChecklistItem,
|
||||
TaskCover,
|
||||
TaskCoverType,
|
||||
TaskExternalLink,
|
||||
@ -160,7 +162,9 @@ 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(
|
||||
column.tasks.filter(row=row)
|
||||
.order_by("position", "-created_at", "id")
|
||||
.values_list(
|
||||
"id",
|
||||
flat=True,
|
||||
)
|
||||
@ -173,9 +177,7 @@ 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 +235,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 +252,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 +315,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()
|
||||
|
||||
@ -364,8 +373,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 +403,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),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@ -599,9 +683,7 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
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):
|
||||
@ -659,13 +741,13 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
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:
|
||||
@ -686,9 +768,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", []),
|
||||
}
|
||||
)
|
||||
@ -721,7 +801,9 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
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")
|
||||
@ -841,11 +923,7 @@ class TaskColumnViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
if column.tasks.exists():
|
||||
return Response(
|
||||
{
|
||||
"detail": (
|
||||
"Переместите задачи в другую колонку перед удалением."
|
||||
)
|
||||
},
|
||||
{"detail": ("Переместите задачи в другую колонку перед удалением.")},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if (
|
||||
@ -1043,9 +1121,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 +1194,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 +1214,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 +1242,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 +1312,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
|
||||
@ -1275,9 +1343,9 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
old_row = task.row
|
||||
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
|
||||
@ -1569,8 +1637,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 +1648,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 +1759,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 +2045,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 +3256,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":
|
||||
|
||||
164
backend/tasks/media.py
Normal file
164
backend/tasks/media.py
Normal file
@ -0,0 +1,164 @@
|
||||
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 task_attachment_media_kind(attachment: TaskAttachment) -> 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 _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 ensure_task_attachment_thumbnail(attachment: TaskAttachment) -> bool:
|
||||
if attachment.thumbnail:
|
||||
return True
|
||||
media_kind = task_attachment_media_kind(attachment)
|
||||
if not media_kind:
|
||||
return False
|
||||
|
||||
try:
|
||||
thumbnail = (
|
||||
_render_image_thumbnail(attachment)
|
||||
if media_kind == "image"
|
||||
else _render_video_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="Миниатюра",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -551,6 +551,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)
|
||||
|
||||
@ -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
|
||||
@ -98,11 +102,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,
|
||||
@ -333,9 +389,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
|
||||
@ -604,6 +658,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 +1034,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,
|
||||
@ -1147,9 +1206,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"]}
|
||||
|
||||
@ -1269,7 +1326,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 +1377,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 +1679,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 +1730,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 +1769,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 +1937,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 +2195,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 +2541,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 +2567,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 +2642,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 +2697,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 +3105,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 +3318,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 +3338,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 +3431,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})
|
||||
|
||||
@ -67,6 +67,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Suspense,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@ -612,7 +613,7 @@ function getMessageCreatedLabel(message?: Message | null) {
|
||||
return message.created || (message.created_at ? formatDate(message.created_at) : "");
|
||||
}
|
||||
|
||||
function TaskCardView({
|
||||
const TaskCardView = memo(function TaskCardView({
|
||||
task,
|
||||
onOpen,
|
||||
onEdit,
|
||||
@ -624,7 +625,7 @@ function TaskCardView({
|
||||
onComplete,
|
||||
menuOpen,
|
||||
menuRef,
|
||||
onToggleMenu,
|
||||
onToggleTaskMenu,
|
||||
}: {
|
||||
task: TaskCard;
|
||||
onOpen: (task: TaskCard) => void;
|
||||
@ -637,7 +638,7 @@ function TaskCardView({
|
||||
onComplete: (task: TaskCard) => void;
|
||||
menuOpen: boolean;
|
||||
menuRef: RefObject<HTMLDivElement | null>;
|
||||
onToggleMenu: () => void;
|
||||
onToggleTaskMenu: (taskId: number) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: `task-${task.id}`,
|
||||
@ -689,7 +690,7 @@ function TaskCardView({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMenu}
|
||||
onClick={() => onToggleTaskMenu(task.id)}
|
||||
className="app-icon-button flex h-7 w-7 items-center justify-center rounded-md"
|
||||
title="Действия"
|
||||
aria-label="Действия с задачей"
|
||||
@ -822,7 +823,7 @@ function TaskCardView({
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function TaskDragOverlayCard({ task }: { task: TaskCard }) {
|
||||
const priority = priorityMeta[task.priority] ?? priorityMeta.medium;
|
||||
@ -1140,7 +1141,7 @@ function TaskColumnCards({
|
||||
onComplete={onCompleteTask}
|
||||
menuOpen={openMenuTaskId === task.id}
|
||||
menuRef={menuRef}
|
||||
onToggleMenu={() => onToggleTaskMenu(task.id)}
|
||||
onToggleTaskMenu={onToggleTaskMenu}
|
||||
/>
|
||||
))
|
||||
) : quickTaskComposer ? null : (
|
||||
@ -3760,6 +3761,33 @@ function TasksPageContent() {
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
const replaceBoardTask = useCallback((updatedTask: TaskCard) => {
|
||||
setBoard((current) => {
|
||||
if (!current || current.id !== updatedTask.board) return current;
|
||||
return {
|
||||
...current,
|
||||
tasks: current.tasks.map((task) => (
|
||||
task.id === updatedTask.id ? updatedTask : task
|
||||
)),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateBoardTask = useCallback((
|
||||
taskId: number,
|
||||
update: (task: TaskCard) => TaskCard,
|
||||
) => {
|
||||
setBoard((current) => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
tasks: current.tasks.map((task) => (
|
||||
task.id === taskId ? update(task) : task
|
||||
)),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleBoardViewState = useCallback((
|
||||
scope: BoardViewStateScope,
|
||||
objectId: number,
|
||||
@ -5826,13 +5854,17 @@ function TasksPageContent() {
|
||||
if (viewTaskId === task.id) setViewTaskId(null);
|
||||
setTaskMenuOpen(false);
|
||||
setCardMenuTaskId(null);
|
||||
await loadBoard(board.id);
|
||||
setBoard((current) => current ? {
|
||||
...current,
|
||||
tasks: current.tasks.filter((item) => item.id !== task.id),
|
||||
tasks_count: Math.max(0, (current.tasks_count || current.tasks.length) - 1),
|
||||
} : current);
|
||||
} catch (deleteError) {
|
||||
setError(getTaskError(deleteError, "Не удалось удалить задачу"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [board, loadBoard, saving, viewTaskId]);
|
||||
}, [board, saving, viewTaskId]);
|
||||
|
||||
const deleteViewedTask = useCallback(async () => {
|
||||
if (!viewTask) return;
|
||||
@ -5847,14 +5879,17 @@ function TasksPageContent() {
|
||||
try {
|
||||
const created = await apiClient.uploadTaskAttachments(viewTask.id, files);
|
||||
setTaskAttachments((current) => [...current, ...created]);
|
||||
await loadBoard(board.id);
|
||||
updateBoardTask(viewTask.id, (task) => ({
|
||||
...task,
|
||||
attachments_count: (task.attachments_count || 0) + created.length,
|
||||
}));
|
||||
if (activityOpen) await loadTaskActivity(viewTask.id);
|
||||
} catch (attachmentError) {
|
||||
setError(getTaskError(attachmentError, "Не удалось загрузить файлы"));
|
||||
} finally {
|
||||
setTaskAttachmentsUploading(false);
|
||||
}
|
||||
}, [activityOpen, board, loadBoard, loadTaskActivity, taskAttachmentsUploading, viewTask]);
|
||||
}, [activityOpen, board, loadTaskActivity, taskAttachmentsUploading, updateBoardTask, viewTask]);
|
||||
|
||||
const uploadViewedTaskCover = useCallback(async (file: File | null) => {
|
||||
if (!board || !viewTask || !file || saving || taskAttachmentsUploading) return;
|
||||
@ -5871,11 +5906,11 @@ function TasksPageContent() {
|
||||
if (!attachment) throw new Error("Файл не был загружен");
|
||||
|
||||
setTaskAttachments((current) => [...current, attachment]);
|
||||
await apiClient.setTaskCover(viewTask.id, {
|
||||
const updatedTask = await apiClient.setTaskCover(viewTask.id, {
|
||||
kind: "attachment",
|
||||
source_id: attachment.id,
|
||||
});
|
||||
await loadBoard(board.id);
|
||||
replaceBoardTask(updatedTask);
|
||||
if (activityOpen) await loadTaskActivity(viewTask.id);
|
||||
setCoverPickerOpen(false);
|
||||
} catch (coverError) {
|
||||
@ -5887,8 +5922,8 @@ function TasksPageContent() {
|
||||
}, [
|
||||
activityOpen,
|
||||
board,
|
||||
loadBoard,
|
||||
loadTaskActivity,
|
||||
replaceBoardTask,
|
||||
saving,
|
||||
taskAttachmentsUploading,
|
||||
viewTask,
|
||||
@ -5924,14 +5959,24 @@ function TasksPageContent() {
|
||||
try {
|
||||
await apiClient.deleteTaskAttachment(viewTask.id, attachment.id);
|
||||
setTaskAttachments((current) => current.filter((item) => item.id !== attachment.id));
|
||||
await loadBoard(board.id);
|
||||
updateBoardTask(viewTask.id, (task) => {
|
||||
const removedCover = (
|
||||
task.cover?.kind === "attachment"
|
||||
&& task.cover.attachment?.id === attachment.id
|
||||
);
|
||||
return {
|
||||
...task,
|
||||
attachments_count: Math.max(0, (task.attachments_count || 0) - 1),
|
||||
...(removedCover ? { cover: null, cover_attachment: null } : {}),
|
||||
};
|
||||
});
|
||||
if (activityOpen) await loadTaskActivity(viewTask.id);
|
||||
} catch (attachmentError) {
|
||||
setError(getTaskError(attachmentError, "Не удалось удалить файл"));
|
||||
} finally {
|
||||
setTaskAttachmentsUploading(false);
|
||||
}
|
||||
}, [activityOpen, board, loadBoard, loadTaskActivity, taskAttachmentsUploading, viewTask]);
|
||||
}, [activityOpen, board, loadTaskActivity, taskAttachmentsUploading, updateBoardTask, viewTask]);
|
||||
|
||||
const toggleViewedTaskCover = useCallback(async (attachment: TaskAttachment) => {
|
||||
if (!board || !viewTask || taskAttachmentsUploading) return;
|
||||
@ -5943,22 +5988,23 @@ function TasksPageContent() {
|
||||
viewTask.cover?.kind === "attachment"
|
||||
&& viewTask.cover.attachment?.id === attachment.id
|
||||
);
|
||||
let updatedTask: TaskCard;
|
||||
if (isCurrentCover) {
|
||||
await apiClient.clearTaskCover(viewTask.id);
|
||||
updatedTask = await apiClient.clearTaskCover(viewTask.id);
|
||||
} else {
|
||||
await apiClient.setTaskCover(viewTask.id, {
|
||||
updatedTask = await apiClient.setTaskCover(viewTask.id, {
|
||||
kind: "attachment",
|
||||
source_id: attachment.id,
|
||||
});
|
||||
}
|
||||
await loadBoard(board.id);
|
||||
replaceBoardTask(updatedTask);
|
||||
if (activityOpen) await loadTaskActivity(viewTask.id);
|
||||
} catch (attachmentError) {
|
||||
setError(getTaskError(attachmentError, "Не удалось изменить обложку задачи"));
|
||||
} finally {
|
||||
setTaskAttachmentsUploading(false);
|
||||
}
|
||||
}, [activityOpen, board, loadBoard, loadTaskActivity, taskAttachmentsUploading, viewTask]);
|
||||
}, [activityOpen, board, loadTaskActivity, replaceBoardTask, taskAttachmentsUploading, viewTask]);
|
||||
|
||||
const openTaskCoverPicker = useCallback(async () => {
|
||||
if (!viewTask || coverPickerLoading) return;
|
||||
@ -5993,11 +6039,11 @@ function TasksPageContent() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.setTaskCover(viewTask.id, {
|
||||
const updatedTask = await apiClient.setTaskCover(viewTask.id, {
|
||||
kind,
|
||||
...(sourceId ? { source_id: sourceId } : {}),
|
||||
});
|
||||
await loadBoard(board.id);
|
||||
replaceBoardTask(updatedTask);
|
||||
if (activityOpen) await loadTaskActivity(viewTask.id);
|
||||
setCoverPickerOpen(false);
|
||||
} catch (coverError) {
|
||||
@ -6005,15 +6051,15 @@ function TasksPageContent() {
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]);
|
||||
}, [activityOpen, board, loadTaskActivity, replaceBoardTask, saving, viewTask]);
|
||||
|
||||
const clearViewedTaskCover = useCallback(async () => {
|
||||
if (!board || !viewTask || saving) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.clearTaskCover(viewTask.id);
|
||||
await loadBoard(board.id);
|
||||
const updatedTask = await apiClient.clearTaskCover(viewTask.id);
|
||||
replaceBoardTask(updatedTask);
|
||||
if (activityOpen) await loadTaskActivity(viewTask.id);
|
||||
setCoverPickerOpen(false);
|
||||
} catch (coverError) {
|
||||
@ -6021,7 +6067,7 @@ function TasksPageContent() {
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]);
|
||||
}, [activityOpen, board, loadTaskActivity, replaceBoardTask, saving, viewTask]);
|
||||
|
||||
const toggleViewedTaskResourceCover = useCallback(async (
|
||||
kind: TaskCoverType,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -260,6 +260,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",
|
||||
|
||||
@ -640,6 +640,7 @@ export interface TaskAttachment {
|
||||
uploaded_by?: User;
|
||||
created_at: string;
|
||||
download_url: string;
|
||||
thumbnail_url?: string | null;
|
||||
}
|
||||
|
||||
export interface TaskChecklistItem {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user