perf(tasks): optimize large boards and media covers

This commit is contained in:
lizerk 2026-08-07 16:24:49 +03:00
parent 73aa685b1d
commit df1deece23
11 changed files with 813 additions and 261 deletions

View File

@ -4,7 +4,7 @@ FROM python:3.13-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev curl && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev curl ffmpeg && rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt

View File

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

View File

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

View File

@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("tasks", "0034_cleanup_empty_checklist_covers"),
]
operations = [
migrations.AddField(
model_name="taskattachment",
name="thumbnail",
field=models.ImageField(
blank=True,
null=True,
upload_to="task_attachment_thumbnails/%Y/%m/%d/",
verbose_name="Миниатюра",
),
),
]

View File

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

View File

@ -1,12 +1,16 @@
import base64
import io
from datetime import date, timedelta
from pathlib import Path
from unittest.mock import patch
import pytest
from PIL import Image
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
@ -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})

View File

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

View File

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

View File

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

View File

@ -640,6 +640,7 @@ export interface TaskAttachment {
uploaded_by?: User;
created_at: string;
download_url: string;
thumbnail_url?: string | null;
}
export interface TaskChecklistItem {