diff --git a/backend/api/v1/documents/fields.py b/backend/api/v1/documents/fields.py index 11d1fcdd..8dc8570e 100644 --- a/backend/api/v1/documents/fields.py +++ b/backend/api/v1/documents/fields.py @@ -74,23 +74,6 @@ class FilerFileField(serializers.Field): if not isinstance(data, UploadedFile): raise serializers.ValidationError("Ожидается файл для загрузки.") - # Проверка размера файла - from django.conf import settings - from django.template.defaultfilters import filesizeformat - - limit = getattr(settings, "DATA_UPLOAD_MAX_MEMORY_SIZE", None) - if limit: - size = getattr(data, "size", None) - if size is None: - raise serializers.ValidationError( - "Невозможно определить размер файла" - ) - if int(size) > int(limit): - human = filesizeformat(limit) - raise serializers.ValidationError( - f"Файл слишком большой: > {human}." - ) - # Получаем пользователя из контекста request = self.context.get("request") owner = getattr(request, "user", None) if request else None diff --git a/backend/api/v1/documents/permissions.py b/backend/api/v1/documents/permissions.py index 0b44482e..17d3326a 100644 --- a/backend/api/v1/documents/permissions.py +++ b/backend/api/v1/documents/permissions.py @@ -62,6 +62,8 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms): "acknowledge", "download", "archive", + "comments", + "document_comment", ): return bool(request.user and request.user.is_authenticated) @@ -108,6 +110,9 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms): if action in ("add_related", "remove_related"): return user.has_perm("documents.change_document", obj) + if action in ("comments", "document_comment"): + return _user_has_document_access(user, obj) + # PUT/PATCH → проверяем django-rules change_document if request.method in ("PUT", "PATCH"): return user.has_perm("documents.change_document", obj) @@ -122,6 +127,8 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms): "acknowledge", "download", "archive", + "comments", + "document_comment", ): return _user_has_document_access(user, obj) diff --git a/backend/api/v1/documents/serializers.py b/backend/api/v1/documents/serializers.py index ab7b9c8c..6b86b9d1 100644 --- a/backend/api/v1/documents/serializers.py +++ b/backend/api/v1/documents/serializers.py @@ -8,6 +8,7 @@ from typing import Any, Dict, Sequence, Iterable from drf_spectacular.utils import extend_schema_field from django.http import QueryDict from django.contrib.auth import get_user_model +from django.db.models import Q from django.utils.text import slugify from documents.models import Document, DocumentAcknowledgement from rest_framework import serializers @@ -281,6 +282,12 @@ class DocumentReadSerializer(serializers.ModelSerializer): modified_by = EmployeeBriefSerializer(read_only=True) recipients = EmployeeBriefSerializer(many=True, read_only=True) departments = DepartmentBriefSerializer(many=True, read_only=True) + acknowledgement_recipients = EmployeeBriefSerializer( + many=True, read_only=True + ) + acknowledgement_departments = DepartmentBriefSerializer( + many=True, read_only=True + ) folder = FolderBriefSerializer(read_only=True) folder_path = serializers.CharField(read_only=True) tags = DocumentTagSerializer(many=True, read_only=True) @@ -293,6 +300,10 @@ class DocumentReadSerializer(serializers.ModelSerializer): source="file.size", read_only=True, allow_null=True ) is_acknowledged = serializers.SerializerMethodField() + acknowledgement_required_for_user = serializers.SerializerMethodField() + acknowledged_count = serializers.SerializerMethodField() + acknowledgement_total = serializers.SerializerMethodField() + comments_count = serializers.SerializerMethodField() linked_tasks = serializers.SerializerMethodField() class Meta: @@ -314,8 +325,15 @@ class DocumentReadSerializer(serializers.ModelSerializer): "sent_to_all", "is_regulation", "acknowledgement_required", # Требуется ли ознакомление + "acknowledgement_for_all", + "acknowledgement_required_for_user", + "acknowledged_count", + "acknowledgement_total", + "comments_count", "departments", "recipients", + "acknowledgement_departments", + "acknowledgement_recipients", "file_url", "file_name", "file_size", @@ -343,6 +361,51 @@ class DocumentReadSerializer(serializers.ModelSerializer): document=obj, user=request.user ).exists() + def get_acknowledgement_required_for_user(self, obj: Document) -> bool: + """Нужно ли текущему сотруднику подтверждать ознакомление.""" + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return False + + audience_ids = getattr(obj, "_acknowledgement_user_ids", None) + if audience_ids is not None: + return request.user.pk in audience_ids + + from documents.audience import user_requires_document_acknowledgement + + return user_requires_document_acknowledgement(obj, request.user) + + def get_acknowledged_count(self, obj: Document) -> int: + """Количество сотрудников из аудитории, уже ознакомившихся.""" + attached = getattr(obj, "_acknowledged_count", None) + if attached is not None: + return int(attached) + + from documents.audience import document_acknowledgement_audience + + audience = document_acknowledgement_audience(obj) + return obj.acknowledgements.filter(user__in=audience).count() + + def get_acknowledgement_total(self, obj: Document) -> int: + """Размер аудитории обязательного ознакомления.""" + attached = getattr(obj, "_acknowledgement_total", None) + if attached is not None: + return int(attached) + + from documents.audience import document_acknowledgement_audience + + return document_acknowledgement_audience(obj).count() + + def get_comments_count(self, obj: Document) -> int: + """Количество неудалённых комментариев документа.""" + attached = getattr(obj, "_comments_count", None) + if attached is not None: + return int(attached) + + from communications.comments_helpers import get_comment_count + + return get_comment_count(obj) + @extend_schema_field(serializers.ListField(child=serializers.DictField())) def get_linked_tasks(self, obj: Document) -> list[dict]: prefetched = getattr(obj, "_linked_task_payloads", None) @@ -432,6 +495,18 @@ class DocumentWriteSerializer(serializers.ModelSerializer): help_text="Список ID отделов-получателей.", ) + acknowledgement_recipient_ids = RecipientIDsField( + write_only=True, + required=False, + help_text="Список ID сотрудников, обязанных ознакомиться.", + ) + + acknowledgement_department_ids = RecipientIDsField( + write_only=True, + required=False, + help_text="Список ID отделов, обязанных ознакомиться.", + ) + tag_ids = RecipientIDsField( write_only=True, required=False, @@ -456,8 +531,11 @@ class DocumentWriteSerializer(serializers.ModelSerializer): "sent_to_all", "is_regulation", "acknowledgement_required", + "acknowledgement_for_all", "department_ids", "recipient_ids", + "acknowledgement_department_ids", + "acknowledgement_recipient_ids", "tag_ids", ) @@ -491,9 +569,27 @@ class DocumentWriteSerializer(serializers.ModelSerializer): elif self.instance is not None: attrs.pop("title", None) - sent_to_all = attrs.get("sent_to_all", True) - recipient_ids = attrs.get("recipient_ids", []) - department_ids = attrs.get("department_ids", []) + instance = self.instance + sent_to_all = attrs.get( + "sent_to_all", + instance.sent_to_all if instance is not None else True, + ) + recipient_ids = set( + attrs.get( + "recipient_ids", + instance.recipients.values_list("pk", flat=True) + if instance is not None + else [], + ) + ) + department_ids = set( + attrs.get( + "department_ids", + instance.departments.values_list("pk", flat=True) + if instance is not None + else [], + ) + ) if sent_to_all is False and not recipient_ids and not department_ids: raise serializers.ValidationError( @@ -504,6 +600,77 @@ class DocumentWriteSerializer(serializers.ModelSerializer): ) } ) + + acknowledgement_required = attrs.get( + "acknowledgement_required", + instance.acknowledgement_required if instance is not None else True, + ) + acknowledgement_for_all = attrs.get( + "acknowledgement_for_all", + instance.acknowledgement_for_all if instance is not None else True, + ) + acknowledgement_recipient_ids = set( + attrs.get( + "acknowledgement_recipient_ids", + instance.acknowledgement_recipients.values_list("pk", flat=True) + if instance is not None + else [], + ) + ) + acknowledgement_department_ids = set( + attrs.get( + "acknowledgement_department_ids", + instance.acknowledgement_departments.values_list("pk", flat=True) + if instance is not None + else [], + ) + ) + + if ( + acknowledgement_required + and not acknowledgement_for_all + and not acknowledgement_recipient_ids + and not acknowledgement_department_ids + ): + raise serializers.ValidationError( + { + "acknowledgement_recipient_ids": ( + "Выберите сотрудников, отделы или включите " + "ознакомление для всех с доступом." + ) + } + ) + + if acknowledgement_required and not acknowledgement_for_all and not sent_to_all: + invalid_departments = acknowledgement_department_ids - department_ids + if invalid_departments: + raise serializers.ValidationError( + { + "acknowledgement_department_ids": ( + "Отдел для ознакомления должен иметь доступ к документу." + ) + } + ) + + allowed_recipients = User.objects.filter(is_active=True).filter( + Q(pk__in=recipient_ids) + | Q( + departments_links__department_id__in=department_ids, + departments_links__is_active=True, + ) + ).values_list("pk", flat=True) + invalid_recipients = acknowledgement_recipient_ids - set( + allowed_recipients + ) + if invalid_recipients: + raise serializers.ValidationError( + { + "acknowledgement_recipient_ids": ( + "Сотрудник для ознакомления должен иметь доступ " + "к документу." + ) + } + ) return attrs def _set_departments( @@ -574,6 +741,22 @@ class DocumentWriteSerializer(serializers.ModelSerializer): logger.info(f"[serializers] Set {count} recipients for doc={doc.id}") return count + def _set_acknowledgement_departments( + self, doc: Document, department_ids: Sequence[int] + ) -> int: + from employees.models import Department + + departments = Department.objects.filter(id__in=set(department_ids)) + doc.acknowledgement_departments.set(departments) + return departments.count() + + def _set_acknowledgement_recipients( + self, doc: Document, recipient_ids: Sequence[int] + ) -> int: + users = User.objects.filter(is_active=True, id__in=set(recipient_ids)) + doc.acknowledgement_recipients.set(users) + return users.count() + def _set_tags(self, doc: Document, tag_ids: Sequence[int]) -> int: """Привязывает теги по списку ID. @@ -618,6 +801,12 @@ class DocumentWriteSerializer(serializers.ModelSerializer): """ recipient_ids = validated_data.pop("recipient_ids", []) department_ids = validated_data.pop("department_ids", []) + acknowledgement_recipient_ids = validated_data.pop( + "acknowledgement_recipient_ids", [] + ) + acknowledgement_department_ids = validated_data.pop( + "acknowledgement_department_ids", [] + ) tag_ids = validated_data.pop("tag_ids", []) request = self.context.get("request") uploader = getattr(request, "user", None) @@ -632,17 +821,29 @@ class DocumentWriteSerializer(serializers.ModelSerializer): # Создаём документ - сигналы из notification_signals.py сработают # автоматически - doc = Document.objects.create( # type: ignore[arg-type] + doc = Document( # type: ignore[arg-type] uploaded_by=uploader, **validated_data, ) + doc._suppress_document_notifications = True + doc.save() logger.info( f"[serializers] Document created id={doc.id} " f"sent_to_all={doc.sent_to_all}" ) - # Для индивидуальной рассылки устанавливаем получателей - # Это вызовет m2m_changed сигнал + if ( + doc.acknowledgement_required + and not doc.acknowledgement_for_all + ): + self._set_acknowledgement_departments( + doc, acknowledgement_department_ids + ) + self._set_acknowledgement_recipients( + doc, acknowledgement_recipient_ids + ) + + # Для индивидуальной доступности устанавливаем получателей. if validated_data.get("sent_to_all") is False: if department_ids: logger.info( @@ -661,6 +862,11 @@ class DocumentWriteSerializer(serializers.ModelSerializer): logger.info(f"[serializers] Setting tags for doc={doc.id}") self._set_tags(doc, tag_ids) + doc._suppress_document_notifications = False + from documents.notifications.handlers import notify_document_audience + + notify_document_audience(doc) + logger.info(f"[serializers] Document creation complete id={doc.id}") return doc @@ -678,6 +884,12 @@ class DocumentWriteSerializer(serializers.ModelSerializer): """ recipient_ids = validated_data.pop("recipient_ids", None) department_ids = validated_data.pop("department_ids", None) + acknowledgement_recipient_ids = validated_data.pop( + "acknowledgement_recipient_ids", None + ) + acknowledgement_department_ids = validated_data.pop( + "acknowledgement_department_ids", None + ) tag_ids = validated_data.pop("tag_ids", None) for f in ( @@ -687,6 +899,7 @@ class DocumentWriteSerializer(serializers.ModelSerializer): "sent_to_all", "is_regulation", "acknowledgement_required", + "acknowledgement_for_all", "file", "folder", ): @@ -694,6 +907,19 @@ class DocumentWriteSerializer(serializers.ModelSerializer): setattr(instance, f, validated_data[f]) instance.save() + if not instance.acknowledgement_required or instance.acknowledgement_for_all: + instance.acknowledgement_recipients.clear() + instance.acknowledgement_departments.clear() + else: + if acknowledgement_department_ids is not None: + self._set_acknowledgement_departments( + instance, acknowledgement_department_ids + ) + if acknowledgement_recipient_ids is not None: + self._set_acknowledgement_recipients( + instance, acknowledgement_recipient_ids + ) + # очищаем получателей и отделы при sent_to_all=true if "sent_to_all" in validated_data and instance.sent_to_all: instance.recipients.clear() diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py index 40671d14..e37bafa1 100644 --- a/backend/api/v1/documents/views.py +++ b/backend/api/v1/documents/views.py @@ -15,6 +15,10 @@ from documents.models import ( DocumentAcknowledgement, DocumentTag, ) +from documents.audience import ( + document_acknowledgement_audience, + user_requires_document_acknowledgement, +) from easy_thumbnails.files import get_thumbnailer from filer.models import Folder import reversion @@ -51,7 +55,12 @@ class DocumentViewSet(ModelViewSet): queryset: QuerySet[Document] = ( Document.objects.all() .select_related("uploaded_by") - .prefetch_related("recipients") + .prefetch_related( + "recipients", + "departments", + "acknowledgement_recipients", + "acknowledgement_departments", + ) ) permission_classes = [DocumentReadOrModelPerms] parser_classes = (MultiPartParser, FormParser, JSONParser) @@ -202,20 +211,123 @@ class DocumentViewSet(ModelViewSet): for document in documents: document._linked_task_payloads = mapping.get(document.id, []) + def _attach_document_metrics(self, documents: list[Document]) -> None: + """Пакетно рассчитывает комментарии и состояние ознакомления.""" + if not documents: + return + + from communications.models import Message + from employees.models import EmployeeDepartment + + document_ids = [document.id for document in documents] + active_user_ids = set( + User.objects.filter(is_active=True).values_list("id", flat=True) + ) + department_ids = { + department.id + for document in documents + for department in ( + list(document.departments.all()) + + list(document.acknowledgement_departments.all()) + ) + } + employees_by_department: dict[int, set[int]] = { + department_id: set() for department_id in department_ids + } + if department_ids: + for department_id, employee_id in EmployeeDepartment.objects.filter( + department_id__in=department_ids, + is_active=True, + employee__is_active=True, + ).values_list("department_id", "employee_id"): + employees_by_department.setdefault(department_id, set()).add( + employee_id + ) + + acknowledged_by_document: dict[int, set[int]] = { + document_id: set() for document_id in document_ids + } + for document_id, user_id in DocumentAcknowledgement.objects.filter( + document_id__in=document_ids, + user__is_active=True, + ).values_list("document_id", "user_id"): + acknowledged_by_document.setdefault(document_id, set()).add(user_id) + + document_content_type = ContentType.objects.get_for_model(Document) + comments_by_document = { + row["chat__context_object_id"]: row["total"] + for row in Message.objects.filter( + chat__type="comments", + chat__context_content_type=document_content_type, + chat__context_object_id__in=document_ids, + is_deleted=False, + ) + .values("chat__context_object_id") + .annotate(total=Count("id")) + } + + for document in documents: + if document.sent_to_all: + access_ids = active_user_ids + else: + access_ids = { + recipient.id + for recipient in document.recipients.all() + if recipient.id in active_user_ids + } + for department in document.departments.all(): + access_ids.update( + employees_by_department.get(department.id, set()) + ) + + if not document.acknowledgement_required: + acknowledgement_ids: set[int] = set() + elif document.acknowledgement_for_all: + acknowledgement_ids = set(access_ids) + else: + acknowledgement_ids = { + recipient.id + for recipient in document.acknowledgement_recipients.all() + if recipient.id in active_user_ids + } + for department in document.acknowledgement_departments.all(): + acknowledgement_ids.update( + employees_by_department.get(department.id, set()) + ) + acknowledgement_ids.intersection_update(access_ids) + + document._acknowledgement_user_ids = acknowledgement_ids + document._acknowledgement_total = len(acknowledgement_ids) + document._acknowledged_count = len( + acknowledgement_ids + & acknowledged_by_document.get(document.id, set()) + ) + document._comments_count = comments_by_document.get(document.id, 0) + def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(queryset) if page is not None: - self._attach_linked_task_payloads(list(page)) - serializer = self.get_serializer(page, many=True) + documents = list(page) + self._attach_linked_task_payloads(documents) + self._attach_document_metrics(documents) + serializer = self.get_serializer(documents, many=True) return self.get_paginated_response(serializer.data) documents = list(queryset) self._attach_linked_task_payloads(documents) + self._attach_document_metrics(documents) serializer = self.get_serializer(documents, many=True) return Response(serializer.data) + def retrieve(self, request, *args, **kwargs): + document = self.get_object() + self._attach_linked_task_payloads([document]) + self._attach_document_metrics([document]) + serializer = self.get_serializer(document) + return Response(serializer.data) + @transaction.atomic def create(self, request, *args, **kwargs): """Создаёт документ и возвращает read-схему + recipient_count. @@ -236,6 +348,7 @@ class DocumentViewSet(ModelViewSet): doc = serializer.save() + self._attach_document_metrics([doc]) read = DocumentReadSerializer(doc, context={"request": request}) data = dict(read.data) @@ -263,6 +376,7 @@ class DocumentViewSet(ModelViewSet): serializer.is_valid(raise_exception=True) doc = serializer.save() + self._attach_document_metrics([doc]) read = DocumentReadSerializer(doc, context={"request": request}) return Response(read.data, status=status.HTTP_200_OK) @@ -395,11 +509,166 @@ class DocumentViewSet(ModelViewSet): }, status=status.HTTP_400_BAD_REQUEST, ) + if not user_requires_document_acknowledgement(doc, request.user): + return Response( + { + "detail": ( + "Для вас подтверждение ознакомления с этим " + "документом не требуется." + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) obj, created = DocumentAcknowledgement.objects.get_or_create( document=doc, user=request.user ) return Response({"ok": True, "already": not created}) + def _document_comment_payload(self, document, message): + """Формат комментария документа для frontend.""" + return { + "id": message.id, + "document": document.id, + "author": EmployeeBriefSerializer( + message.author, + context={"request": self.request}, + ).data, + "text": message.content, + "parent": message.reply_to_id, + "created_at": message.created_at, + "updated_at": message.edited_at or message.created_at, + "is_edited": message.is_edited, + "replies_count": message.direct_replies.filter( + is_deleted=False + ).count(), + "can_edit": message.author_id == self.request.user.id, + "can_delete": bool( + message.author_id == self.request.user.id + or self.request.user.is_staff + or self.request.user.is_superuser + ), + } + + @action(detail=True, methods=["get", "post"], url_path="comments") + def comments(self, request, pk=None): + """Получение и создание комментариев документа через Communications.""" + from communications import comments_helpers + from communications.models import Message + + document = self.get_object() + if request.method in ("GET", "HEAD"): + messages = comments_helpers.get_comments(document) + return Response( + [ + self._document_comment_payload(document, message) + for message in messages + ] + ) + + text = str(request.data.get("text") or "").strip() + if not text: + return Response( + {"text": ["Это поле не может быть пустым."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + reply_to = None + parent_id = request.data.get("parent_id") or request.data.get("parent") + if parent_id: + chat = comments_helpers.get_comments_chat_if_exists(document) + if chat is None: + return Response( + {"parent_id": ["Родительский комментарий не найден."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + reply_to = Message.objects.get( + id=parent_id, + chat=chat, + is_deleted=False, + ) + except (Message.DoesNotExist, ValueError, TypeError): + return Response( + {"parent_id": ["Родительский комментарий не найден."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + message = comments_helpers.create_comment( + document, + request.user, + text, + reply_to=reply_to, + ) + return Response( + self._document_comment_payload(document, message), + status=status.HTTP_201_CREATED, + ) + + @action( + detail=True, + methods=["patch", "delete"], + url_path=r"comments/(?P\d+)", + url_name="document-comment", + ) + def document_comment(self, request, pk=None, comment_id=None): + """Редактирование автором и мягкое удаление комментария.""" + from communications import comments_helpers + from communications.models import Message + + document = self.get_object() + chat = comments_helpers.get_comments_chat_if_exists(document) + if chat is None: + return Response( + {"detail": "Комментарий не найден."}, + status=status.HTTP_404_NOT_FOUND, + ) + try: + message = Message.objects.get( + id=comment_id, + chat=chat, + is_deleted=False, + ) + except Message.DoesNotExist: + return Response( + {"detail": "Комментарий не найден."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if request.method == "PATCH": + if message.author_id != request.user.id: + return Response( + {"detail": "Редактировать комментарий может только его автор."}, + status=status.HTTP_403_FORBIDDEN, + ) + text = str(request.data.get("text") or "").strip() + if not text: + return Response( + {"text": ["Это поле не может быть пустым."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + comments_helpers.update_comment( + message, + text, + edited_by=request.user, + ) + return Response(self._document_comment_payload(document, message)) + + if not ( + message.author_id == request.user.id + or request.user.is_staff + or request.user.is_superuser + ): + return Response( + {"detail": "Нет прав на удаление комментария."}, + status=status.HTTP_403_FORBIDDEN, + ) + comments_helpers.delete_comment( + message, + deleted_by=request.user, + soft_delete=True, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True, methods=["get"]) def acknowledgements(self, request, pk=None): """Ведомость ознакомлений: доступна пользователям с доступом к документу. @@ -408,19 +677,7 @@ class DocumentViewSet(ModelViewSet): """ doc = self.get_object() q = (request.query_params.get("search") or "").strip() - base = User.objects.filter(is_active=True) - - if not doc.sent_to_all: - # Собираем ID получателей из recipients и departments - recipient_ids = set(doc.recipients.values_list("pk", flat=True)) - - # Добавляем активных сотрудников из отделов - for department in doc.departments.all(): - recipient_ids.update( - emp.id for emp in department.active_employees - ) - - base = base.filter(pk__in=recipient_ids) + base = document_acknowledgement_audience(doc) if q: base = base.filter( diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index 59f35dce..01a67e3b 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -25,6 +25,8 @@ from procurement.models import ( MaintenanceRecord, ApprovalRoute, ProcurementItem, + ProcurementItemAttachment, + ProcurementRequestActivity, ProcurementRequest, ProcurementRequestView, ProcurementSettings, @@ -42,6 +44,28 @@ PROBLEM_ITEM_STATUSES = { ProcurementItemExecutionStatus.DEFECTIVE, } + +class ProcurementRequestActivitySerializer(serializers.ModelSerializer): + actor = EmployeeBriefSerializer(read_only=True) + action_display = serializers.CharField( + source="get_action_display", + read_only=True, + ) + + class Meta: + model = ProcurementRequestActivity + fields = [ + "id", + "action", + "action_display", + "actor", + "object_kind", + "object_id", + "metadata", + "created_at", + ] + read_only_fields = fields + DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") UNIT_DISPLAY_LABELS = { "шт": "штуки", @@ -316,6 +340,34 @@ class ProcurementRequestSummaryMixin(serializers.Serializer): ] +class ProcurementItemAttachmentSerializer(serializers.ModelSerializer): + uploaded_by_name = serializers.CharField( + source="uploaded_by.get_full_name", + read_only=True, + ) + download_url = serializers.SerializerMethodField() + + class Meta: + model = ProcurementItemAttachment + fields = [ + "id", + "file_name", + "file_size", + "mime_type", + "uploaded_by", + "uploaded_by_name", + "created_at", + "download_url", + ] + read_only_fields = fields + + def get_download_url(self, obj): + return ( + f"/api/v1/procurement/items/{obj.item_id}/" + f"attachments/{obj.id}/" + ) + + class ProcurementItemSerializer(serializers.ModelSerializer): """Сериализатор для позиций заявки.""" @@ -350,6 +402,11 @@ class ProcurementItemSerializer(serializers.ModelSerializer): ) execution_status_display = serializers.SerializerMethodField() comments_count = serializers.SerializerMethodField() + attachments = ProcurementItemAttachmentSerializer(many=True, read_only=True) + attachments_count = serializers.IntegerField( + source="attachments.count", + read_only=True, + ) @extend_schema_field(serializers.IntegerField()) def get_comments_count(self, obj): @@ -387,6 +444,8 @@ class ProcurementItemSerializer(serializers.ModelSerializer): "executor_comment", "initial_comment", "comments_count", + "attachments", + "attachments_count", "equipment", "created_at", ] @@ -396,6 +455,8 @@ class ProcurementItemSerializer(serializers.ModelSerializer): "total_price", "execution_status_display", "comments_count", + "attachments", + "attachments_count", ] def validate(self, attrs): @@ -451,6 +512,7 @@ class ProcurementItemCreateSerializer(serializers.ModelSerializer): class Meta: model = ProcurementItem fields = [ + "id", "name", "description", "quantity", @@ -466,6 +528,7 @@ class ProcurementItemCreateSerializer(serializers.ModelSerializer): "executor_comment", "initial_comment", ] + read_only_fields = ["id"] def validate(self, attrs): return _validate_item_quantities(attrs, self.instance) @@ -873,6 +936,7 @@ class ProcurementRequestDetailSerializer( ) is_editable = serializers.BooleanField(read_only=True) comments_count = serializers.IntegerField(read_only=True, default=0) + activity_count = serializers.SerializerMethodField() can_current_user_approve = serializers.SerializerMethodField() can_current_user_submit_for_approval = serializers.SerializerMethodField() can_current_user_start_work = serializers.SerializerMethodField() @@ -905,6 +969,10 @@ class ProcurementRequestDetailSerializer( user = getattr(request, "user", None) return ProcurementApprovalResolver.user_can_process_items(user, obj) + @extend_schema_field(serializers.IntegerField()) + def get_activity_count(self, obj): + return obj.activities.count() + class Meta: model = ProcurementRequest fields = [ @@ -944,6 +1012,7 @@ class ProcurementRequestDetailSerializer( "required_approval_priorities", "is_editable", "comments_count", + "activity_count", "linked_tasks", "can_current_user_approve", "can_current_user_submit_for_approval", @@ -980,6 +1049,7 @@ class ProcurementRequestDetailSerializer( "processing_department_name", "fulfillment_status_display", "comments_count", + "activity_count", "linked_tasks", "can_current_user_approve", "can_current_user_submit_for_approval", diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 4363b3c1..f41cf1ac 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -1,12 +1,17 @@ """ViewSets для API модуля закупок.""" +from pathlib import Path + from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from django.http import FileResponse, Http404 from drf_spectacular.utils import OpenApiParameter, extend_schema from django.db.models import Q from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, permissions, status, viewsets from rest_framework.decorators import action +from rest_framework.parsers import MultiPartParser from rest_framework.response import Response from employees.models import Department, EmployeeDepartment @@ -26,11 +31,15 @@ from procurement.models import ( EquipmentTransferLog, MaintenanceRecord, ProcurementItem, + ProcurementItemAttachment, + ProcurementActivityAction, + ProcurementActivityObjectKind, ProcurementRequest, ProcurementRequestView, ProcurementSettings, Supplier, ) +from procurement.activity import create_procurement_activity from procurement.notifications.handlers import ( notify_executor_reassigned, notify_item_comment, @@ -63,11 +72,13 @@ from .serializers import ( ProcurementDepartmentStatsSerializer, ProcurementOverviewStatsSerializer, ProcurementItemSerializer, + ProcurementItemAttachmentSerializer, ProcurementManualApprovalStepsSerializer, ProcurementNotificationReadResponseSerializer, ProcurementRequestCreateOptionsSerializer, ProcurementRequestCreateSerializer, ProcurementRequestDetailSerializer, + ProcurementRequestActivitySerializer, ProcurementRequestListSerializer, SupplierSerializer, ) @@ -81,7 +92,10 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): "processing_department", "requestor", "executor", - ).prefetch_related("items", "approvals__approver") + ).prefetch_related( + "items__attachments__uploaded_by", + "approvals__approver", + ) filter_backends = [ DjangoFilterBackend, filters.SearchFilter, @@ -116,10 +130,50 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): "notify_arrival", "set_viewed", "approval_options", + "activity", ]: return ProcurementRequestDetailSerializer return ProcurementRequestListSerializer + @action(detail=True, methods=["get"], url_path="activity") + def activity(self, request, pk=None): + procurement_request = self.get_object() + queryset = procurement_request.activities.select_related("actor").order_by( + "-created_at", + "-id", + ) + serializer = ProcurementRequestActivitySerializer(queryset, many=True) + return Response(serializer.data) + + def perform_update(self, serializer): + tracked_fields = [ + "title", + "description", + "department_id", + "processing_department_id", + "urgency", + "actual_cost", + ] + before = { + field: getattr(serializer.instance, field) + for field in tracked_fields + } + procurement_request = serializer.save() + changed_fields = [ + field + for field in tracked_fields + if before[field] != getattr(procurement_request, field) + ] + if changed_fields: + create_procurement_activity( + procurement_request, + self.request.user, + ProcurementActivityAction.UPDATED, + object_kind=ProcurementActivityObjectKind.REQUEST, + object_id=procurement_request.pk, + metadata={"fields": changed_fields}, + ) + def get_permissions(self): """Выбрать права доступа в зависимости от действия.""" if self.action in ["approve", "reject"]: @@ -573,6 +627,14 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): message, actor=request.user, ) + create_procurement_activity( + procurement_request, + request.user, + ProcurementActivityAction.COMMENT_ADDED, + object_kind=ProcurementActivityObjectKind.COMMENT, + object_id=message.pk, + metadata={"comment_text": message.content}, + ) author_ser = EmployeeBriefSerializer(message.author) return Response( { @@ -626,6 +688,14 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): deleted_by=request.user, soft_delete=True, ) + create_procurement_activity( + procurement_request, + request.user, + ProcurementActivityAction.COMMENT_REMOVED, + object_kind=ProcurementActivityObjectKind.COMMENT, + object_id=message.pk, + metadata={"comment_text": message.content}, + ) return Response(status=status.HTTP_204_NO_CONTENT) @action(detail=True, methods=["post"]) @@ -1042,9 +1112,24 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): ) procurement_request.executor = request.user - procurement_request.started_at = timezone.now() - procurement_request.save( - update_fields=["executor", "started_at", "updated_at"], + procurement_request._notification_actor = request.user + update_fields = ["executor", "updated_at"] + if procurement_request.started_at is None: + procurement_request.started_at = timezone.now() + update_fields.append("started_at") + procurement_request.save(update_fields=update_fields) + create_procurement_activity( + procurement_request, + request.user, + ProcurementActivityAction.EXECUTOR_REASSIGNED, + object_kind=ProcurementActivityObjectKind.REQUEST, + object_id=procurement_request.pk, + metadata={ + "previous_executor": previous_executor.get_full_name(), + "previous_executor_id": previous_executor.pk, + "new_executor": request.user.get_full_name(), + "new_executor_id": request.user.pk, + }, ) notify_executor_reassigned( procurement_request, @@ -1183,6 +1268,14 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): procurement_request._notification_actor = request.user procurement_request.recalculate_fulfillment_status(save=True) procurement_request.refresh_from_db() + create_procurement_activity( + procurement_request, + request.user, + ProcurementActivityAction.ALL_ITEMS_RECEIVED, + object_kind=ProcurementActivityObjectKind.REQUEST, + object_id=procurement_request.pk, + metadata={"items_count": len(items_to_update)}, + ) serializer = self.get_serializer(procurement_request) return Response(serializer.data) @@ -1208,6 +1301,13 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): ) notify_request_arrival(procurement_request, actor=request.user) + create_procurement_activity( + procurement_request, + request.user, + ProcurementActivityAction.ARRIVAL_NOTIFIED, + object_kind=ProcurementActivityObjectKind.REQUEST, + object_id=procurement_request.pk, + ) from django.contrib.contenttypes.models import ContentType from notifications.models import Notification @@ -1333,6 +1433,16 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): class ProcurementItemViewSet(viewsets.ModelViewSet): """ViewSet для позиций заявок.""" + EXECUTION_FIELDS = { + "actual_unit_price", + "execution_status", + "executor_comment", + "expected_delivery_dates", + "links", + "ordered_quantity", + "received_quantity", + } + NOTIFICATION_TRACKED_FIELDS = [ "name", "quantity", @@ -1449,12 +1559,122 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): "confirm_received", "cancel_received", "cancel_issue", + "attachments", + "attachment_detail", ]: permission_classes = [permissions.IsAuthenticated] else: permission_classes = [CanManageProcurementRequest] return [permission() for permission in permission_classes] + def _check_item_read_permission(self, item): + from rest_framework.exceptions import PermissionDenied + + if not self._can_access_item_comments(self.request.user, item.request): + raise PermissionDenied("Нет доступа к этой позиции") + + @action( + detail=True, + methods=["get", "post"], + parser_classes=[MultiPartParser], + url_path="attachments", + ) + def attachments(self, request, pk=None): + item = self.get_object() + self._check_item_read_permission(item) + + if request.method == "GET": + serializer = ProcurementItemAttachmentSerializer( + item.attachments.select_related("uploaded_by").all(), + many=True, + context=self.get_serializer_context(), + ) + return Response(serializer.data) + + self._check_item_edit_permission(item) + files = request.FILES.getlist("files") + if not files: + return Response( + {"files": "Выберите хотя бы один файл."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + created = [ + ProcurementItemAttachment.objects.create( + item=item, + file=uploaded_file, + file_name=Path(uploaded_file.name).name, + file_size=uploaded_file.size, + mime_type=uploaded_file.content_type or "", + uploaded_by=request.user, + ) + for uploaded_file in files + ] + for attachment in created: + create_procurement_activity( + item.request, + request.user, + ProcurementActivityAction.ATTACHMENT_ADDED, + object_kind=ProcurementActivityObjectKind.ATTACHMENT, + object_id=attachment.pk, + metadata={ + "item_id": item.pk, + "item_name": item.name, + "file_name": attachment.file_name, + }, + ) + serializer = ProcurementItemAttachmentSerializer( + created, + many=True, + context=self.get_serializer_context(), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action( + detail=True, + methods=["get", "delete"], + url_path=r"attachments/(?P\d+)", + ) + def attachment_detail(self, request, pk=None, attachment_id=None): + item = self.get_object() + self._check_item_read_permission(item) + try: + attachment = item.attachments.get(pk=attachment_id) + except ProcurementItemAttachment.DoesNotExist as exc: + raise Http404 from exc + + if request.method == "DELETE": + self._check_item_edit_permission(item) + attachment_id_value = attachment.pk + file_name = attachment.file_name + attachment.delete() + create_procurement_activity( + item.request, + request.user, + ProcurementActivityAction.ATTACHMENT_REMOVED, + object_kind=ProcurementActivityObjectKind.ATTACHMENT, + object_id=attachment_id_value, + metadata={ + "item_id": item.pk, + "item_name": item.name, + "file_name": file_name, + }, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + if not attachment.file or not attachment.file.name: + raise Http404 + try: + file_handle = attachment.file.open("rb") + except FileNotFoundError as exc: + raise Http404 from exc + return FileResponse( + file_handle, + as_attachment=True, + filename=attachment.file_name, + content_type=attachment.mime_type or "application/octet-stream", + ) + def _can_use_item_quick_action(self, user, procurement_request): return ( procurement_request.requestor_id == user.id @@ -1498,6 +1718,19 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): return ProcurementItemExecutionStatus.ORDERED return ProcurementItemExecutionStatus.PENDING + def _record_item_update(self, item, user, changed_fields): + create_procurement_activity( + item.request, + user, + ProcurementActivityAction.ITEM_UPDATED, + object_kind=ProcurementActivityObjectKind.ITEM, + object_id=item.pk, + metadata={ + "item_name": item.name, + "fields": list(changed_fields), + }, + ) + def _reopen_completed_request(self, procurement_request, user): if procurement_request.status != ProcurementStatus.COMPLETED: return @@ -1596,15 +1829,59 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): # Нет прав raise PermissionDenied("Вы не можете изменять позиции в этой заявке") + def _starts_execution(self, item, validated_data): + """Проверить, меняются ли данные из блока исполнения позиции.""" + return any( + field in validated_data + and getattr(item, field) != validated_data[field] + for field in self.EXECUTION_FIELDS + ) + + def _start_request_for_execution(self, procurement_request): + """Назначить исполнителя при первом заполнении исполнения позиции.""" + user = self.request.user + if ( + procurement_request.status + not in {ProcurementStatus.APPROVED, ProcurementStatus.WAITING} + or procurement_request.executor_id + or not self._can_process_request_items(user, procurement_request) + or not ProcurementApprovalResolver.user_can_start_work( + user, + procurement_request, + ) + ): + return + + procurement_request.executor = user + procurement_request.status = ProcurementStatus.IN_PROGRESS + if procurement_request.started_at is None: + procurement_request.started_at = timezone.now() + procurement_request._notification_actor = user + procurement_request.save( + update_fields=[ + "executor", + "status", + "started_at", + "updated_at", + ] + ) + + @transaction.atomic def perform_update(self, serializer): """Проверяем права при обновлении позиции.""" item = self.get_object() + procurement_request = ProcurementRequest.objects.select_for_update().get( + pk=item.request_id, + ) + item.request = procurement_request + serializer.instance.request = procurement_request self._check_item_edit_permission(item) - procurement_request = item.request before = { field: getattr(item, field) for field in self.NOTIFICATION_TRACKED_FIELDS } + if self._starts_execution(item, serializer.validated_data): + self._start_request_for_execution(procurement_request) procurement_request._notification_actor = self.request.user updated_item = serializer.save() after = { @@ -1622,6 +1899,11 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): actor=self.request.user, changed_fields=changed_fields, ) + self._record_item_update( + updated_item, + self.request.user, + changed_fields, + ) @action(detail=True, methods=["post"]) def report_issue(self, request, pk=None): @@ -1662,6 +1944,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): actor=user, changed_fields=["execution_status"], ) + self._record_item_update(item, user, ["execution_status"]) if procurement_request.requestor_id == user.id: notify_item_issue_reported(item, actor=user) @@ -1739,6 +2022,11 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): "received_quantity", ], ) + self._record_item_update( + item, + user, + ["execution_status", "ordered_quantity", "received_quantity"], + ) item.refresh_from_db() serializer = self.get_serializer(item) @@ -1771,6 +2059,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): actor=user, changed_fields=["execution_status"], ) + self._record_item_update(item, user, ["execution_status"]) item.refresh_from_db() serializer = self.get_serializer(item) @@ -1849,6 +2138,11 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): "received_quantity", ], ) + self._record_item_update( + item, + user, + ["execution_status", "ordered_quantity", "received_quantity"], + ) item.refresh_from_db() serializer = self.get_serializer(item) @@ -1907,6 +2201,18 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): message, actor=request.user, ) + create_procurement_activity( + item.request, + request.user, + ProcurementActivityAction.COMMENT_ADDED, + object_kind=ProcurementActivityObjectKind.COMMENT, + object_id=message.pk, + metadata={ + "item_id": item.pk, + "item_name": item.name, + "comment_text": message.content, + }, + ) author_ser = EmployeeBriefSerializer(message.author) return Response( { @@ -1967,6 +2273,18 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): deleted_by=request.user, soft_delete=True, ) + create_procurement_activity( + item.request, + request.user, + ProcurementActivityAction.COMMENT_REMOVED, + object_kind=ProcurementActivityObjectKind.COMMENT, + object_id=message.pk, + metadata={ + "item_id": item.pk, + "item_name": item.name, + "comment_text": message.content, + }, + ) return Response(status=status.HTTP_204_NO_CONTENT) @action(detail=True, methods=["post"]) diff --git a/backend/api/v1/schedule/views.py b/backend/api/v1/schedule/views.py index 7fb74fe2..16904335 100644 --- a/backend/api/v1/schedule/views.py +++ b/backend/api/v1/schedule/views.py @@ -465,6 +465,9 @@ class ScheduleEventViewSet(viewsets.ModelViewSet): if calendar_id: qs = qs.filter(calendar_id=calendar_id) + if search := self.request.query_params.get("search", "").strip(): + qs = qs.filter(Q(title__icontains=search) | Q(description__icontains=search)) + # Фильтр по диапазону дат start_str = self.request.query_params.get("start") end_str = self.request.query_params.get("end") diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 9f66e008..39d43f75 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -1,6 +1,9 @@ +from urllib.parse import urlsplit + from django.db.models import Max from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType +from django.urls import reverse from django.utils import timezone from drf_spectacular.utils import extend_schema_field from rest_framework import serializers @@ -37,8 +40,12 @@ from tasks.access import ( from tasks.models import ( Task, TaskActivity, + TaskAttachment, TaskBoard, + TaskBoardAccessScope, + TaskChecklistItem, TaskColumn, + TaskExternalLink, TaskLabel, TaskLinkedObject, TaskLinkedObjectKind, @@ -162,6 +169,9 @@ class TaskSerializer(serializers.ModelSerializer): linked_attendance_records_count = serializers.SerializerMethodField() linked_objects_count = serializers.SerializerMethodField() comments_count = serializers.SerializerMethodField() + attachments_count = serializers.SerializerMethodField() + checklist_total = serializers.SerializerMethodField() + checklist_completed = serializers.SerializerMethodField() class Meta: model = Task @@ -196,6 +206,9 @@ class TaskSerializer(serializers.ModelSerializer): "linked_attendance_records_count", "linked_objects_count", "comments_count", + "attachments_count", + "checklist_total", + "checklist_completed", "created_at", "updated_at", ] @@ -219,6 +232,9 @@ class TaskSerializer(serializers.ModelSerializer): "linked_attendance_records_count", "linked_objects_count", "comments_count", + "attachments_count", + "checklist_total", + "checklist_completed", "created_at", "updated_at", ] @@ -307,7 +323,7 @@ class TaskSerializer(serializers.ModelSerializer): def get_linked_objects_count(self, obj): if hasattr(obj, "linked_objects_count"): return obj.linked_objects_count - return obj.linked_objects.count() + return obj.linked_objects.count() + obj.external_links.count() @extend_schema_field(serializers.IntegerField()) def get_comments_count(self, obj): @@ -315,6 +331,29 @@ class TaskSerializer(serializers.ModelSerializer): return obj.comments_count return get_comment_count(obj) + @extend_schema_field(serializers.IntegerField()) + def get_attachments_count(self, obj): + if hasattr(obj, "attachments_count"): + return obj.attachments_count + return obj.attachments.count() + + @extend_schema_field(serializers.IntegerField()) + def get_checklist_total(self, obj): + if hasattr(obj, "checklist_total"): + return obj.checklist_total + return obj.checklist_items.count() + + @extend_schema_field(serializers.IntegerField()) + def get_checklist_completed(self, obj): + if hasattr(obj, "checklist_completed"): + return obj.checklist_completed + prefetched = getattr(obj, "_prefetched_objects_cache", {}).get( + "checklist_items" + ) + if prefetched is not None: + return sum(item.is_completed for item in prefetched) + return obj.checklist_items.filter(is_completed=True).count() + def validate(self, attrs): request = self.context.get("request") user = getattr(request, "user", None) @@ -401,6 +440,7 @@ class TaskBoardSerializer(serializers.ModelSerializer): "id", "name", "description", + "access_scope", "created_by", "members", "member_details", @@ -416,6 +456,28 @@ class TaskBoardSerializer(serializers.ModelSerializer): "updated_at", ] + def validate(self, attrs): + attrs = super().validate(attrs) + access_scope = attrs.get("access_scope") + if access_scope is None: + has_members = bool(attrs.get("members")) + has_departments = bool(attrs.get("departments")) + if has_members or has_departments: + access_scope = TaskBoardAccessScope.RESTRICTED + elif "members" in attrs and "departments" in attrs: + access_scope = TaskBoardAccessScope.ALL + else: + access_scope = getattr( + self.instance, + "access_scope", + TaskBoardAccessScope.ALL, + ) + attrs["access_scope"] = access_scope + if access_scope != TaskBoardAccessScope.RESTRICTED: + attrs["members"] = [] + attrs["departments"] = [] + return attrs + @extend_schema_field(serializers.BooleanField()) def get_is_default_for_current_user(self, obj): request = self.context.get("request") @@ -572,6 +634,89 @@ class TaskActivitySerializer(serializers.ModelSerializer): read_only_fields = fields +class TaskAttachmentSerializer(serializers.ModelSerializer): + uploaded_by = EmployeeBriefSerializer(read_only=True) + download_url = serializers.SerializerMethodField() + + class Meta: + model = TaskAttachment + fields = [ + "id", + "task", + "file_name", + "file_size", + "mime_type", + "uploaded_by", + "created_at", + "download_url", + ] + read_only_fields = fields + + @extend_schema_field(serializers.URLField()) + def get_download_url(self, obj): + url = reverse( + "api:v1:tasks:task-attachment-download", + 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) + completed_by = EmployeeBriefSerializer(read_only=True) + + class Meta: + model = TaskChecklistItem + fields = [ + "id", + "task", + "title", + "position", + "is_completed", + "created_by", + "completed_by", + "completed_at", + "created_at", + "updated_at", + ] + read_only_fields = [ + "id", + "task", + "created_by", + "completed_by", + "completed_at", + "created_at", + "updated_at", + ] + + def validate_title(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError("Введите текст пункта чек-листа.") + return value + + +class TaskExternalLinkSerializer(serializers.ModelSerializer): + created_by = EmployeeBriefSerializer(read_only=True) + + class Meta: + model = TaskExternalLink + fields = ["id", "task", "url", "title", "created_by", "created_at"] + read_only_fields = ["id", "task", "created_by", "created_at"] + + def validate_url(self, value): + value = value.strip() + if urlsplit(value).scheme.lower() not in {"http", "https"}: + raise serializers.ValidationError( + "Разрешены только ссылки с протоколом http или https." + ) + return value + + def validate_title(self, value): + return value.strip() + + class TaskLinkedCalendarEventSerializer(serializers.ModelSerializer): created_by = EmployeeBriefSerializer(read_only=True) event = serializers.SerializerMethodField() diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index ebeed54e..1e580a25 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -1,5 +1,10 @@ +import mimetypes +from pathlib import Path +from urllib.parse import urlsplit + from django.contrib.contenttypes.models import ContentType from django.contrib.auth import get_user_model +from django.db import transaction from django.db.models import ( Count, IntegerField, @@ -10,6 +15,9 @@ from django.db.models import ( Value, ) from django.db.models.functions import Coalesce +from django.http import FileResponse +from django.shortcuts import get_object_or_404 +from django.utils import timezone from attendance.models import AttendanceRecord from communications import comments_helpers from communications.models import Message @@ -19,7 +27,7 @@ from feed.models import Post from guests.models import Guest, GuestVisit from procurement.models import ProcurementRequest from requests_app.models import Request as EmployeeRequest -from rest_framework import filters, status, viewsets +from rest_framework import filters, parsers, status, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -41,8 +49,11 @@ from tasks.models import ( Task, TaskActivity, TaskActivityAction, + TaskActivityObjectKind, + TaskAttachment, TaskBoard, TaskColumn, + TaskExternalLink, TaskLabel, TaskLinkedObject, TaskLinkedObjectKind, @@ -63,8 +74,11 @@ from tasks.realtime import ( from .serializers import ( EmployeeBriefSerializer, TaskActivitySerializer, + TaskAttachmentSerializer, TaskBoardSerializer, + TaskChecklistItemSerializer, TaskColumnSerializer, + TaskExternalLinkSerializer, TaskLabelSerializer, TaskLinkedCalendarEventSerializer, TaskLinkedAttendanceRecordSerializer, @@ -123,6 +137,22 @@ def normalize_column_positions(column): Task.objects.filter(id=task_id).update(position=(index + 1) * 1000) +def send_task_checklist_update(task, event, item_id): + send_task_board_update( + task.board, + event, + "checklist_item", + item_id, + extra={ + "task_id": task.id, + "checklist_total": task.checklist_items.count(), + "checklist_completed": task.checklist_items.filter( + is_completed=True + ).count(), + }, + ) + + class TaskBoardViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = TaskBoardSerializer @@ -141,6 +171,9 @@ class TaskBoardViewSet(viewsets.ModelViewSet): "tasks__assignee", "tasks__labels", "tasks__linked_objects", + "tasks__external_links", + "tasks__attachments", + "tasks__checklist_items", ) .annotate(tasks_count=Count("tasks", distinct=True)) .distinct() @@ -330,7 +363,7 @@ class TaskViewSet(viewsets.ModelViewSet): ) ) .select_related("board", "column", "created_by", "assignee") - .prefetch_related("labels") + .prefetch_related("labels", "checklist_items") .annotate( linked_posts_count=Count( "linked_objects", @@ -402,11 +435,21 @@ class TaskViewSet(viewsets.ModelViewSet): ), distinct=True, ), - linked_objects_count=Count("linked_objects", distinct=True), + linked_objects_count=( + Count("linked_objects", distinct=True) + + Count("external_links", distinct=True) + ), comments_count=Coalesce( Subquery(comments_subquery, output_field=IntegerField()), Value(0), ), + attachments_count=Count("attachments", distinct=True), + checklist_total=Count("checklist_items", distinct=True), + checklist_completed=Count( + "checklist_items", + filter=Q(checklist_items__is_completed=True), + distinct=True, + ), ) ) params = self.request.query_params @@ -471,6 +514,49 @@ class TaskViewSet(viewsets.ModelViewSet): instance.delete() send_task_board_update(board, "deleted", "task", task_id) + def _move_task(self, task, column, position, actor): + old_column = task.column + if position in (None, ""): + max_position = ( + Task.objects.filter(column=column).exclude(id=task.id).aggregate( + Max("position") + )["position__max"] + or 0 + ) + position = max_position + 1000 + + task.column = column + task.position = position + task.save( + update_fields=["column", "position", "completed_at", "updated_at"] + ) + + normalize_column_positions(column) + if old_column.id != column.id: + normalize_column_positions(old_column) + task.refresh_from_db() + + create_task_activity( + task, + actor, + TaskActivityAction.MOVED, + metadata={ + "from_column_id": old_column.id, + "from_column": old_column.name, + "to_column_id": column.id, + "to_column": column.name, + }, + ) + notify_task_moved(task, actor, old_column, column) + send_task_board_update( + task.board, + "moved", + "task", + task.id, + extra={"column_id": task.column_id}, + ) + return task + @action(detail=True, methods=["post"]) def move(self, request, pk=None): task = self.get_object() @@ -494,15 +580,8 @@ class TaskViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) - old_column = task.column if position in (None, ""): - max_position = ( - Task.objects.filter(column=column).exclude(id=task.id).aggregate( - Max("position") - )["position__max"] - or 0 - ) - position = max_position + 1000 + position = None else: try: position = int(position) @@ -512,37 +591,88 @@ class TaskViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) - task.column = column - task.position = position - task.save(update_fields=["column", "position", "completed_at", "updated_at"]) - - normalize_column_positions(column) - if old_column.id != column.id: - normalize_column_positions(old_column) - task.refresh_from_db() - + task = self._move_task(task, column, position, request.user) serializer = self.get_serializer(task) - create_task_activity( - task, - request.user, - TaskActivityAction.MOVED, - metadata={ - "from_column_id": old_column.id, - "from_column": old_column.name, - "to_column_id": column.id, - "to_column": column.name, - }, - ) - notify_task_moved(task, request.user, old_column, column) - send_task_board_update( - task.board, - "moved", - "task", - task.id, - extra={"column_id": task.column_id}, - ) return Response(serializer.data) + @action(detail=True, methods=["post"]) + def claim(self, request, pk=None): + visible_task = self.get_object() + with transaction.atomic(): + task = ( + Task.objects.select_for_update() + .select_related("board", "column", "assignee") + .get(id=visible_task.id) + ) + + if task.completed_at or task.column.is_done: + return Response( + {"detail": "Нельзя взять в работу завершённую задачу."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if task.assignee_id: + if task.assignee_id == request.user.id: + return Response(self.get_serializer(task).data) + return Response( + {"detail": "У задачи уже есть исполнитель."}, + status=status.HTTP_409_CONFLICT, + ) + + task.assignee = request.user + task.save(update_fields=["assignee", "updated_at"]) + create_task_activity( + task, + request.user, + TaskActivityAction.CLAIMED, + metadata={"assignee_id": request.user.id}, + ) + notify_task_updated( + task, + request.user, + {"assignee_id": None}, + {"assignee_id": request.user.id}, + ) + send_task_board_update( + task.board, + "claimed", + "task", + task.id, + extra={"assignee_id": request.user.id}, + ) + + return Response(self.get_serializer(task).data) + + @action(detail=True, methods=["post"]) + def complete(self, request, pk=None): + task = self.get_object() + if task.assignee_id != request.user.id: + return Response( + {"detail": "Завершить задачу может только назначенный исполнитель."}, + status=status.HTTP_403_FORBIDDEN, + ) + + if task.column.is_done: + return Response(self.get_serializer(task).data) + + done_column = ( + TaskColumn.objects.filter( + board_id=task.board_id, + is_done=True, + is_archived=False, + ) + .order_by("position", "id") + .first() + ) + if done_column is None: + return Response( + {"detail": "На доске не настроена завершающая колонка."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + task = self._move_task(task, done_column, None, request.user) + return Response(self.get_serializer(task).data) + @action(detail=True, methods=["get"], url_path="activity") def activity(self, request, pk=None): task = self.get_object() @@ -553,12 +683,343 @@ class TaskViewSet(viewsets.ModelViewSet): serializer = TaskActivitySerializer(queryset, many=True) return Response(serializer.data) + @action( + detail=True, + methods=["get", "post"], + url_path="attachments", + parser_classes=[parsers.MultiPartParser, parsers.FormParser], + ) + def attachments(self, request, pk=None): + task = self.get_object() + if request.method in ("GET", "HEAD"): + queryset = task.attachments.select_related("uploaded_by") + serializer = TaskAttachmentSerializer( + queryset, + many=True, + context=self.get_serializer_context(), + ) + return Response(serializer.data) + + files = request.FILES.getlist("files") + if not files and request.FILES.get("file"): + files = [request.FILES["file"]] + if not files: + return Response( + {"files": ["Выберите хотя бы один файл."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + created = [] + for uploaded in files: + file_name = Path(uploaded.name.replace("\\", "/")).name[:255] + attachment = TaskAttachment.objects.create( + task=task, + file=uploaded, + file_name=file_name, + file_size=uploaded.size, + mime_type=(uploaded.content_type or "")[:255], + uploaded_by=request.user, + ) + created.append(attachment) + create_task_activity( + task, + request.user, + TaskActivityAction.ATTACHMENT_ADDED, + object_id=attachment.id, + metadata={"file_name": attachment.file_name}, + ) + + send_task_board_update( + task.board, + "attachments_updated", + "task", + task.id, + extra={"attachments_count": task.attachments.count()}, + ) + serializer = TaskAttachmentSerializer( + created, + many=True, + context=self.get_serializer_context(), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action( + detail=True, + methods=["get"], + url_path=r"attachments/(?P\d+)/download", + url_name="attachment-download", + ) + def attachment_download(self, request, pk=None, attachment_id=None): + task = self.get_object() + attachment = get_object_or_404( + task.attachments, + id=attachment_id, + ) + try: + file_handle = attachment.file.open("rb") + except (FileNotFoundError, OSError): + return Response( + {"detail": "Файл не найден в хранилище."}, + status=status.HTTP_404_NOT_FOUND, + ) + + content_type = attachment.mime_type or ( + mimetypes.guess_type(attachment.file_name)[0] + or "application/octet-stream" + ) + response = FileResponse( + file_handle, + as_attachment=True, + filename=attachment.file_name, + content_type=content_type, + ) + response["X-Content-Type-Options"] = "nosniff" + return response + + @action( + detail=True, + methods=["delete"], + url_path=r"attachments/(?P\d+)", + url_name="attachment-detail", + ) + def attachment_detail(self, request, pk=None, attachment_id=None): + task = self.get_object() + attachment = get_object_or_404( + task.attachments, + id=attachment_id, + ) + attachment_id = attachment.id + file_name = attachment.file_name + attachment.delete() + create_task_activity( + task, + request.user, + TaskActivityAction.ATTACHMENT_REMOVED, + object_id=attachment_id, + metadata={"file_name": file_name}, + ) + send_task_board_update( + task.board, + "attachments_updated", + "task", + task.id, + extra={"attachments_count": task.attachments.count()}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=True, methods=["get", "post"], url_path="checklist") + def checklist(self, request, pk=None): + task = self.get_object() + if request.method in ("GET", "HEAD"): + serializer = TaskChecklistItemSerializer( + task.checklist_items.select_related("created_by", "completed_by"), + many=True, + ) + return Response(serializer.data) + + serializer = TaskChecklistItemSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + position = serializer.validated_data.get("position") + if position is None: + max_position = ( + task.checklist_items.aggregate(Max("position"))["position__max"] or 0 + ) + position = max_position + 1000 + is_completed = serializer.validated_data.get("is_completed", False) + item = serializer.save( + task=task, + created_by=request.user, + position=position, + completed_by=request.user if is_completed else None, + completed_at=timezone.now() if is_completed else None, + ) + create_task_activity( + task, + request.user, + TaskActivityAction.CHECKLIST_ITEM_ADDED, + object_kind=TaskActivityObjectKind.CHECKLIST_ITEM, + object_id=item.id, + metadata={ + "item_title": item.title, + "is_completed": item.is_completed, + }, + ) + send_task_checklist_update(task, "checklist_updated", item.id) + return Response( + TaskChecklistItemSerializer(item).data, + status=status.HTTP_201_CREATED, + ) + + @action( + detail=True, + methods=["patch", "delete"], + url_path=r"checklist/(?P\d+)", + url_name="checklist-item-detail", + ) + def checklist_item_detail(self, request, pk=None, item_id=None): + task = self.get_object() + item = get_object_or_404(task.checklist_items, id=item_id) + + if request.method == "DELETE": + item_id = item.id + item_title = item.title + item.delete() + create_task_activity( + task, + request.user, + TaskActivityAction.CHECKLIST_ITEM_REMOVED, + object_kind=TaskActivityObjectKind.CHECKLIST_ITEM, + object_id=item_id, + metadata={"item_title": item_title}, + ) + send_task_checklist_update(task, "checklist_updated", item_id) + return Response(status=status.HTTP_204_NO_CONTENT) + + serializer = TaskChecklistItemSerializer( + item, + data=request.data, + partial=True, + ) + serializer.is_valid(raise_exception=True) + previous_title = item.title + previous_completed = item.is_completed + changed_fields = [ + field + for field, value in serializer.validated_data.items() + if getattr(item, field) != value + ] + if not changed_fields: + return Response(TaskChecklistItemSerializer(item).data) + + completion_metadata = {} + if "is_completed" in serializer.validated_data: + is_completed = serializer.validated_data["is_completed"] + completion_metadata = { + "completed_by": request.user if is_completed else None, + "completed_at": timezone.now() if is_completed else None, + } + item = serializer.save(**completion_metadata) + if previous_completed != item.is_completed: + activity_action = ( + TaskActivityAction.CHECKLIST_ITEM_COMPLETED + if item.is_completed + else TaskActivityAction.CHECKLIST_ITEM_REOPENED + ) + else: + activity_action = TaskActivityAction.CHECKLIST_ITEM_UPDATED + create_task_activity( + task, + request.user, + activity_action, + object_kind=TaskActivityObjectKind.CHECKLIST_ITEM, + object_id=item.id, + metadata={ + "item_title": item.title, + "previous_title": previous_title, + "is_completed": item.is_completed, + "fields": changed_fields, + }, + ) + send_task_checklist_update(task, "checklist_updated", item.id) + return Response(TaskChecklistItemSerializer(item).data) + + @action(detail=True, methods=["get", "post"], url_path="external-links") + def external_links(self, request, pk=None): + task = self.get_object() + if request.method in ("GET", "HEAD"): + serializer = TaskExternalLinkSerializer( + task.external_links.select_related("created_by"), + many=True, + ) + return Response(serializer.data) + + input_serializer = TaskExternalLinkSerializer(data=request.data) + input_serializer.is_valid(raise_exception=True) + url = input_serializer.validated_data["url"] + title = input_serializer.validated_data.get("title", "") + default_title = title or urlsplit(url).netloc or url + link, created = TaskExternalLink.objects.get_or_create( + task=task, + url=url, + defaults={ + "title": default_title, + "created_by": request.user, + }, + ) + if not created and title and title != link.title: + link.title = title + link.save(update_fields=["title"]) + + if created: + create_task_activity( + task, + request.user, + TaskActivityAction.LINKED, + object_kind=TaskActivityObjectKind.EXTERNAL_LINK, + object_id=link.id, + metadata={ + "object_label": link.title or link.url, + "object_type": "Ссылка", + "url": link.url, + }, + ) + send_task_board_update( + task.board, + "linked", + "external_link", + link.id, + extra={"task_id": task.id, "link_id": link.id}, + ) + serializer = TaskExternalLinkSerializer(link) + return Response( + serializer.data, + status=( + status.HTTP_201_CREATED if created else status.HTTP_200_OK + ), + ) + + @action( + detail=True, + methods=["delete"], + url_path=r"external-links/(?P\d+)", + url_name="external-link-detail", + ) + def external_link_detail(self, request, pk=None, link_id=None): + task = self.get_object() + link = get_object_or_404(task.external_links, id=link_id) + link_id = link.id + title = link.title or link.url + url = link.url + link.delete() + create_task_activity( + task, + request.user, + TaskActivityAction.UNLINKED, + object_kind=TaskActivityObjectKind.EXTERNAL_LINK, + object_id=link_id, + metadata={ + "object_label": title, + "object_type": "Ссылка", + "url": url, + }, + ) + send_task_board_update( + task.board, + "unlinked", + "external_link", + link_id, + extra={"task_id": task.id, "link_id": link_id}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + def _comment_payload(self, task, message): return { "id": message.id, "task": task.id, "author": EmployeeBriefSerializer(message.author).data, "text": message.content, + "is_edited": message.is_edited, + "edited_at": message.edited_at, "created_at": message.created_at, } @@ -584,6 +1045,14 @@ class TaskViewSet(viewsets.ModelViewSet): author=request.user, content=text, ) + create_task_activity( + task, + request.user, + TaskActivityAction.COMMENT_ADDED, + object_kind=TaskActivityObjectKind.COMMENT, + object_id=message.id, + metadata={"comment_text": message.content}, + ) notify_task_comment(task, message) send_task_board_update( task.board, @@ -599,8 +1068,9 @@ class TaskViewSet(viewsets.ModelViewSet): @action( detail=True, - methods=["delete"], + methods=["patch", "delete"], url_path=r"comments/(?P\d+)", + url_name="delete-comment", ) def delete_comment(self, request, pk=None, comment_id=None): task = self.get_object() @@ -623,6 +1093,49 @@ class TaskViewSet(viewsets.ModelViewSet): status=status.HTTP_404_NOT_FOUND, ) + if request.method == "PATCH": + if message.author_id != request.user.id: + return Response( + {"detail": "Редактировать комментарий может только его автор."}, + status=status.HTTP_403_FORBIDDEN, + ) + + text = request.data.get("text", "").strip() + if not text: + return Response( + {"text": ["Это поле не может быть пустым."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + previous_text = message.content + if text == previous_text: + return Response(self._comment_payload(task, message)) + + comments_helpers.update_comment( + message, + text, + edited_by=request.user, + ) + create_task_activity( + task, + request.user, + TaskActivityAction.COMMENT_EDITED, + object_kind=TaskActivityObjectKind.COMMENT, + object_id=message.id, + metadata={ + "previous_text": previous_text, + "comment_text": message.content, + }, + ) + send_task_board_update( + task.board, + "comment_edited", + "task", + task.id, + extra={"comment_id": message.id}, + ) + return Response(self._comment_payload(task, message)) + if not ( request.user.is_staff or request.user.is_superuser @@ -633,11 +1146,20 @@ class TaskViewSet(viewsets.ModelViewSet): status=status.HTTP_403_FORBIDDEN, ) + comment_text = message.content comments_helpers.delete_comment( message=message, deleted_by=request.user, soft_delete=True, ) + create_task_activity( + task, + request.user, + TaskActivityAction.COMMENT_REMOVED, + object_kind=TaskActivityObjectKind.COMMENT, + object_id=message.id, + metadata={"comment_text": comment_text}, + ) send_task_board_update( task.board, "comment_deleted", diff --git a/backend/communications/comments_helpers.py b/backend/communications/comments_helpers.py index 4e702b83..e9994b73 100644 --- a/backend/communications/comments_helpers.py +++ b/backend/communications/comments_helpers.py @@ -25,7 +25,7 @@ from typing import Optional from django.contrib.contenttypes.models import ContentType from django.db.models import QuerySet -from .models import Chat, Message, MessageAttachment +from .models import Chat, Message, MessageAttachment, MessageEditHistory def get_or_create_comments_chat( @@ -261,7 +261,10 @@ def delete_comment(message: Message, deleted_by, soft_delete: bool = True): def update_comment( - message: Message, new_content: str, mark_edited: bool = True + message: Message, + new_content: str, + mark_edited: bool = True, + edited_by=None, ): """ Обновить текст комментария. @@ -270,12 +273,23 @@ def update_comment( message: Message - комментарий для обновления new_content: str - новый текст mark_edited: bool - пометить как отредактированный + edited_by: User - автор изменения для истории редактирования Examples: >>> update_comment(comment, "Новый текст") """ from django.utils import timezone + if message.content == new_content: + return message + + if mark_edited: + MessageEditHistory.objects.create( + message=message, + previous_content=message.content, + edited_by=edited_by, + ) + message.content = new_content if mark_edited: @@ -285,6 +299,8 @@ def update_comment( else: message.save(update_fields=["content"]) + return message + def get_comments_chat_if_exists(obj) -> Optional[Chat]: """ diff --git a/backend/documents/admin.py b/backend/documents/admin.py index 4c5999b0..85a4b76f 100644 --- a/backend/documents/admin.py +++ b/backend/documents/admin.py @@ -75,7 +75,12 @@ class DocumentAdmin(admin.ModelAdmin): ) list_filter = ("sent_to_all", "is_regulation", "uploaded_at", "departments") search_fields = ("title", "description", "file__name", "file__description") - filter_horizontal = ("recipients", "departments") + filter_horizontal = ( + "recipients", + "departments", + "acknowledgement_recipients", + "acknowledgement_departments", + ) actions = ["send_document"] # Для django-reversion @@ -112,7 +117,14 @@ class DocumentAdmin(admin.ModelAdmin): ( _("Ознакомление"), { - "fields": ("recipients_summary", "pending_list"), + "fields": ( + "acknowledgement_required", + "acknowledgement_for_all", + "acknowledgement_departments", + "acknowledgement_recipients", + "recipients_summary", + "pending_list", + ), }, ), ) @@ -236,14 +248,15 @@ class DocumentAdmin(admin.ModelAdmin): def acknowledgement_status(self, obj): """Статус ознакомления (N из M).""" - recipients = set(self.get_recipients_qs(obj)) + recipients = set(self.get_acknowledgement_recipients_qs(obj)) total = len(recipients) if total == 0: return "—" acked_count = DocumentAcknowledgement.objects.filter( - document=obj + document=obj, + user__in=recipients, ).count() if acked_count == total: @@ -286,6 +299,12 @@ class DocumentAdmin(admin.ModelAdmin): return list(recipients_set) + def get_acknowledgement_recipients_qs(self, obj): + """Возвращает сотрудников, обязанных ознакомиться.""" + from documents.audience import document_acknowledgement_audience + + return list(document_acknowledgement_audience(obj)) + def recipients_summary(self, obj): """Краткий список получателей.""" recipients = self.get_recipients_qs(obj) @@ -303,7 +322,7 @@ class DocumentAdmin(admin.ModelAdmin): def pending_list(self, obj): """Список сотрудников, которые ещё не ознакомились.""" - recipients = set(self.get_recipients_qs(obj)) + recipients = set(self.get_acknowledgement_recipients_qs(obj)) acked_ids = set( DocumentAcknowledgement.objects.filter(document=obj).values_list( "user_id", flat=True diff --git a/backend/documents/audience.py b/backend/documents/audience.py new file mode 100644 index 00000000..ffdbe188 --- /dev/null +++ b/backend/documents/audience.py @@ -0,0 +1,51 @@ +"""Единые правила вычисления аудиторий документа.""" + +from django.contrib.auth import get_user_model +from django.db.models import Q, QuerySet + +from .models import Document + + +Employee = get_user_model() + + +def document_access_audience(document: Document) -> QuerySet: + """Активные сотрудники, которым документ адресован для просмотра.""" + employees = Employee.objects.filter(is_active=True) + if document.sent_to_all: + return employees + + return employees.filter( + Q(pk__in=document.recipients.values("pk")) + | Q( + departments_links__department__in=document.departments.all(), + departments_links__is_active=True, + ) + ).distinct() + + +def document_acknowledgement_audience(document: Document) -> QuerySet: + """Активные сотрудники, обязанные ознакомиться с документом.""" + access_audience = document_access_audience(document) + if not document.acknowledgement_required: + return access_audience.none() + if document.acknowledgement_for_all: + return access_audience + + selected = Employee.objects.filter(is_active=True).filter( + Q(pk__in=document.acknowledgement_recipients.values("pk")) + | Q( + departments_links__department__in=( + document.acknowledgement_departments.all() + ), + departments_links__is_active=True, + ) + ) + return selected.filter(pk__in=access_audience.values("pk")).distinct() + + +def user_requires_document_acknowledgement(document: Document, user) -> bool: + """Нужно ли конкретному сотруднику подтверждать ознакомление.""" + if not getattr(user, "is_authenticated", False): + return False + return document_acknowledgement_audience(document).filter(pk=user.pk).exists() diff --git a/backend/documents/migrations/0017_document_acknowledgement_audience.py b/backend/documents/migrations/0017_document_acknowledgement_audience.py new file mode 100644 index 00000000..ee7102e9 --- /dev/null +++ b/backend/documents/migrations/0017_document_acknowledgement_audience.py @@ -0,0 +1,57 @@ +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "0016_document_is_regulation"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="document", + name="acknowledgement_required", + field=models.BooleanField( + default=True, + help_text="Если включено — сотрудники должны подтвердить ознакомление", + verbose_name="Требуется ознакомление", + ), + ), + migrations.AddField( + model_name="document", + name="acknowledgement_for_all", + field=models.BooleanField( + default=True, + help_text=( + "Если включено — ознакомиться должны все сотрудники, " + "которым доступен документ" + ), + verbose_name="Ознакомление для всех с доступом", + ), + ), + migrations.AddField( + model_name="document", + name="acknowledgement_departments", + field=models.ManyToManyField( + blank=True, + help_text=( + "Сотрудники выбранных отделов должны ознакомиться с документом" + ), + related_name="documents_requiring_acknowledgement", + to="employees.department", + verbose_name="Отделы для ознакомления", + ), + ), + migrations.AddField( + model_name="document", + name="acknowledgement_recipients", + field=models.ManyToManyField( + blank=True, + help_text="Выбранные сотрудники должны ознакомиться с документом", + related_name="documents_requiring_acknowledgement", + to=settings.AUTH_USER_MODEL, + verbose_name="Сотрудники для ознакомления", + ), + ), + ] diff --git a/backend/documents/models.py b/backend/documents/models.py index e2418ad2..6c97af49 100644 --- a/backend/documents/models.py +++ b/backend/documents/models.py @@ -97,12 +97,21 @@ class Document(models.Model): # Требование ознакомления acknowledgement_required = models.BooleanField( _("Требуется ознакомление"), - default=False, + default=True, help_text=_( "Если включено — сотрудники должны подтвердить ознакомление" ), ) + acknowledgement_for_all = models.BooleanField( + _("Ознакомление для всех с доступом"), + default=True, + help_text=_( + "Если включено — ознакомиться должны все сотрудники, " + "которым доступен документ" + ), + ) + is_regulation = models.BooleanField( _("Регламент"), default=False, @@ -128,6 +137,24 @@ class Document(models.Model): ), related_name="document_recipients", ) + acknowledgement_departments = models.ManyToManyField( + "employees.Department", + verbose_name=_("Отделы для ознакомления"), + blank=True, + related_name="documents_requiring_acknowledgement", + help_text=_( + "Сотрудники выбранных отделов должны ознакомиться с документом" + ), + ) + acknowledgement_recipients = models.ManyToManyField( + User, + verbose_name=_("Сотрудники для ознакомления"), + blank=True, + related_name="documents_requiring_acknowledgement", + help_text=_( + "Выбранные сотрудники должны ознакомиться с документом" + ), + ) # Связанные документы related_documents = models.ManyToManyField( diff --git a/backend/documents/notifications/handlers.py b/backend/documents/notifications/handlers.py index d9f72d22..dbce0346 100644 --- a/backend/documents/notifications/handlers.py +++ b/backend/documents/notifications/handlers.py @@ -27,9 +27,19 @@ logger = logging.getLogger(__name__) Employee = get_user_model() -def _document_ready_payload(document, uploader_name): +def _document_ready_payload(document, uploader_name, recipient=None): """Собирает единый текст и данные уведомления о новом документе.""" - acknowledgement_required = bool(document.acknowledgement_required) + if recipient is None: + acknowledgement_required = bool( + document.acknowledgement_required + and document.acknowledgement_for_all + ) + else: + from documents.audience import user_requires_document_acknowledgement + + acknowledgement_required = user_requires_document_acknowledgement( + document, recipient + ) return { "description": MessageTemplates.document_ready( uploader_name, @@ -64,7 +74,7 @@ def notify_document_ready(document, recipient): ) uploader_name = get_uploader_name(document.uploaded_by) - payload = _document_ready_payload(document, uploader_name) + payload = _document_ready_payload(document, uploader_name, recipient) notify.send( sender=document.uploaded_by, @@ -101,10 +111,11 @@ def notify_all_employees(document): # Создаём уведомления created_count = 0 uploader_name = get_uploader_name(document.uploaded_by) - payload = _document_ready_payload(document, uploader_name) - for employee in active_employees: try: + payload = _document_ready_payload( + document, uploader_name, employee + ) notify.send( sender=document.uploaded_by, recipient=employee, @@ -169,11 +180,10 @@ def notify_specific_users(document, user_ids): # Уведомляем получателей created_count = 0 uploader_name = get_uploader_name(document.uploaded_by) - payload = _document_ready_payload(document, uploader_name) - for user_id in user_ids: try: user = Employee.objects.get(id=user_id) + payload = _document_ready_payload(document, uploader_name, user) logger.debug(f"[handlers] Creating notification for user={user_id}") notify.send( sender=document.uploaded_by, @@ -254,10 +264,11 @@ def notify_department_employees(document, department_ids): # Создаём уведомления created_count = 0 uploader_name = get_uploader_name(document.uploaded_by) - payload = _document_ready_payload(document, uploader_name) - for employee in all_employees: try: + payload = _document_ready_payload( + document, uploader_name, employee + ) notify.send( sender=document.uploaded_by, recipient=employee, @@ -278,6 +289,22 @@ def notify_department_employees(document, department_ids): logger.info(f"[handlers] Created {created_count} notifications") +def notify_document_audience(document): + """Уведомляет аудиторию доступа после полной настройки документа.""" + if document.sent_to_all: + notify_all_employees(document) + return + + from documents.audience import document_access_audience + + recipient_ids = set( + document_access_audience(document).values_list("pk", flat=True) + ) + if document.uploaded_by_id: + recipient_ids.discard(document.uploaded_by_id) + notify_specific_users(document, recipient_ids) + + def notify_all_acknowledged(document, total_recipients, acknowledged_count): """ Уведомляет загрузившего о том, что все ознакомились с документом. diff --git a/backend/documents/notifications/signals.py b/backend/documents/notifications/signals.py index 5ccc570f..fabd59f3 100644 --- a/backend/documents/notifications/signals.py +++ b/backend/documents/notifications/signals.py @@ -41,6 +41,9 @@ def create_document_notification(sender, instance, created, **kwargs): f"created={created} sent_to_all={instance.sent_to_all}" ) + if getattr(instance, "_suppress_document_notifications", False): + return + if not created: return @@ -73,6 +76,9 @@ def notify_specific_recipients(sender, instance, action, pk_set, **kwargs): f"action={action} pk_set={pk_set} sent_to_all={instance.sent_to_all}" ) + if getattr(instance, "_suppress_document_notifications", False): + return + if action != "post_add": logger.info(f"[signals] Skipping (action={action} != 'post_add')") return @@ -101,6 +107,9 @@ def notify_department_employees_signal( f"id={instance.pk} action={action} pk_set={pk_set}" ) + if getattr(instance, "_suppress_document_notifications", False): + return + if action != "post_add": logger.info(f"[signals] Skipping (action={action} != 'post_add')") return @@ -134,23 +143,15 @@ def check_all_acknowledged(sender, instance, created, **kwargs): ) return - # Определяем общее количество получателей - if document.sent_to_all: - total_recipients = Employee.objects.filter(is_active=True).count() - else: - # Считаем получателей из recipients + departments - recipients_count = document.recipients.count() + from documents.audience import document_acknowledgement_audience - # Добавляем сотрудников из отделов - department_employees = set() - for department in document.departments.all(): - department_employees.update(department.active_employees) - - # Исключаем дубликаты с recipients - total_recipients = recipients_count + len(department_employees) + acknowledgement_audience = document_acknowledgement_audience(document) + total_recipients = acknowledgement_audience.count() # Проверяем количество ознакомившихся - acknowledged_count = document.acknowledgements.count() + acknowledged_count = document.acknowledgements.filter( + user__in=acknowledgement_audience + ).count() # Если все ознакомились - уведомляем загрузившего if ( diff --git a/backend/documents/rules.py b/backend/documents/rules.py index b310e7a5..fd55bca6 100644 --- a/backend/documents/rules.py +++ b/backend/documents/rules.py @@ -104,6 +104,16 @@ def has_acknowledged_document(user, document): ).exists() +@rules.predicate +def requires_document_acknowledgement(user, document): + """Пользователь входит в отдельную аудиторию ознакомления.""" + if document is None: + return False + from documents.audience import user_requires_document_acknowledgement + + return user_requires_document_acknowledgement(document, user) + + # ----------------------------------------------------------------------------- # ПРАВИЛА (rules) # ----------------------------------------------------------------------------- @@ -133,7 +143,10 @@ rules.add_rule( ) # Отметка об ознакомлении (только те, у кого есть доступ) -rules.add_rule("documents.acknowledge_document", has_document_access) +rules.add_rule( + "documents.acknowledge_document", + has_document_access & requires_document_acknowledgement, +) # Просмотр списка ознакомившихся (все, у кого есть доступ к документу) rules.add_rule( diff --git a/backend/employees/ldap/utils/image_utils.py b/backend/employees/ldap/utils/image_utils.py index 4d9d367f..1917136b 100644 --- a/backend/employees/ldap/utils/image_utils.py +++ b/backend/employees/ldap/utils/image_utils.py @@ -12,6 +12,58 @@ except ImportError: Image = None # type: ignore +def _encode_jpeg(image, quality: int) -> bytes: + """Кодирует JPEG, обходя ограничения буфера libjpeg для сложных фото.""" + options = { + "format": "JPEG", + "quality": quality, + "subsampling": 0, + } + + buffer = io.BytesIO() + try: + image.save( + buffer, + optimize=True, + progressive=True, + **options, + ) + except OSError: + # Некоторые сборки libjpeg не могут оптимизировать высокоэнтропийное + # изображение через BytesIO. Обычный JPEG остаётся совместимым с AD. + buffer = io.BytesIO() + image.save( + buffer, + optimize=False, + progressive=False, + **options, + ) + return buffer.getvalue() + + +def _best_jpeg_within_limit( + image, + *, + min_quality: int, + max_quality: int, + max_bytes: int, +) -> bytes | None: + """Возвращает JPEG максимального качества в заданном лимите.""" + best = None + low, high = min_quality, max_quality + + while low <= high: + quality = (low + high) // 2 + encoded = _encode_jpeg(image, quality) + if len(encoded) <= max_bytes: + best = encoded + low = quality + 1 + else: + high = quality - 1 + + return best + + def normalize_avatar_to_jpeg( data: bytes, *, size_px: int = 384, max_kb: int = 100 ) -> bytes: @@ -42,6 +94,10 @@ def normalize_avatar_to_jpeg( """ if not data: raise ValueError("avatar: пустые данные") + if size_px <= 0: + raise ValueError("avatar: размер изображения должен быть положительным") + if max_kb <= 0: + raise ValueError("avatar: лимит размера должен быть положительным") if Image is None: raise RuntimeError( "Pillow не установлен. Установите 'Pillow' для обработки аватаров." @@ -86,46 +142,46 @@ def normalize_avatar_to_jpeg( # вместо resize, который растягивает изображение img.thumbnail((size_px, size_px), Image.Resampling.LANCZOS) - # Бинарный поиск по качеству для соблюдения max_kb - # КАЧЕСТВО: Повышен минимум до 75, используется progressive и 4:4:4 - lo, hi = 75, 98 - best = None - while lo <= hi: - mid = (lo + hi) // 2 - buf = io.BytesIO() - # МАКСИМАЛЬНОЕ КАЧЕСТВО: - # - progressive: лучше сжатие, плавная загрузка - # - subsampling=0: 4:4:4 (без потери цветности, лучше для лиц) - # - optimize: оптимизация таблиц Хаффмана - img.save( - buf, - format="JPEG", - quality=mid, - optimize=True, - progressive=True, - subsampling=0, # 4:4:4 - без потери цветности - ) - kb = len(buf.getvalue()) // 1024 - if kb <= max_kb: - best = buf.getvalue() - lo = mid + 1 - else: - hi = mid - 1 + max_bytes = max_kb * 1024 + working = img - if best is None: - # минимально возможное (с теми же настройками качества) - buf = io.BytesIO() - img.save( - buf, - format="JPEG", - quality=75, - optimize=True, - progressive=True, - subsampling=0, + # Сначала сохраняем высокое качество. Если сложное изображение не + # помещается, уменьшаем разрешение, не нарушая жёсткий лимит LDAP. + while True: + best = _best_jpeg_within_limit( + working, + min_quality=75, + max_quality=98, + max_bytes=max_bytes, ) - best = buf.getvalue() + if best is not None: + return best - return best + if max(working.size) <= 64: + break + + next_size = tuple(max(1, int(side * 0.85)) for side in working.size) + working = working.resize(next_size, Image.Resampling.LANCZOS) + + # Аварийный диапазон качества нужен только для нетипично маленького + # лимита или высокоэнтропийного изображения. + while True: + best = _best_jpeg_within_limit( + working, + min_quality=20, + max_quality=74, + max_bytes=max_bytes, + ) + if best is not None: + return best + + if max(working.size) <= 1: + raise RuntimeError( + "avatar: невозможно уложить JPEG в заданный лимит" + ) + + next_size = tuple(max(1, int(side * 0.75)) for side in working.size) + working = working.resize(next_size, Image.Resampling.LANCZOS) except Exception as exc: # pragma: no cover raise RuntimeError(f"avatar: ошибка нормализации: {exc}") from exc diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 4cb1d656..398134ae 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -6,8 +6,6 @@ from pathlib import Path from celery.schedules import crontab from dotenv import load_dotenv -from dotenv import load_dotenv - load_dotenv() BASE_DIR = Path(__file__).resolve().parent.parent @@ -243,11 +241,11 @@ MEDIA_URL = "/media/" # ├── chat_attachments/ ← Вложения в чате # └── temp/ ← Временные файлы -# Лимиты загрузки файлов -# По умолчанию Django ограничивает загрузку до 2.5MB -# Увеличиваем до 10MB для аватаров и документов -DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10 MB -FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10 MB +# Параметры обработки загрузок +# Ограничивает только нефайловые данные запроса. Размер файлов не ограничен. +DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 +# Крупные и небольшие файлы сразу пишутся во временное хранилище, а не в RAM. +FILE_UPLOAD_MAX_MEMORY_SIZE = 0 # ----------------------------------------------------------------------------- # ЛОГИРОВАНИЕ diff --git a/backend/feed/models.py b/backend/feed/models.py index 08d457bb..7588569b 100644 --- a/backend/feed/models.py +++ b/backend/feed/models.py @@ -17,17 +17,6 @@ from .constants import ( Employee = get_user_model() -# --- helpers: валидаторы размера файлов --- -def validate_file_size(max_mb: int): - max_bytes = max_mb * 1024 * 1024 - - def _v(f): - if f and getattr(f, "size", 0) > max_bytes: - raise ValidationError(f"Файл превышает {max_mb} MB.") - - return _v - - IMAGE_EXTS = ["jpg", "jpeg", "png", "gif", "webp"] FILE_EXTS = [ "pdf", @@ -49,12 +38,13 @@ FILE_EXTS = [ @deconstructible class FileSizeValidator: + """Оставлен как no-op для совместимости со старыми миграциями.""" + def __init__(self, max_mb: int): self.max_mb = int(max_mb) def __call__(self, f): - if f and getattr(f, "size", 0) > self.max_mb * 1024 * 1024: - raise ValidationError(f"Файл превышает {self.max_mb} MB.") + return None def __eq__(self, other): return ( diff --git a/backend/procurement/activity.py b/backend/procurement/activity.py new file mode 100644 index 00000000..d4c6fc65 --- /dev/null +++ b/backend/procurement/activity.py @@ -0,0 +1,21 @@ +from .models import ProcurementRequestActivity + + +def create_procurement_activity( + procurement_request, + actor, + action, + *, + object_kind="", + object_id=None, + metadata=None, +): + """Write one immutable entry to the procurement request audit trail.""" + return ProcurementRequestActivity.objects.create( + request=procurement_request, + actor=actor if getattr(actor, "is_authenticated", False) else None, + action=action, + object_kind=object_kind or "", + object_id=object_id, + metadata=metadata or {}, + ) diff --git a/backend/procurement/apps.py b/backend/procurement/apps.py index ce8c8a54..193b7996 100644 --- a/backend/procurement/apps.py +++ b/backend/procurement/apps.py @@ -9,5 +9,6 @@ class ProcurementConfig(AppConfig): def ready(self): """Подключение сигналов при загрузке приложения.""" import procurement.notifications.signals # noqa: F401 + import procurement.signals # noqa: F401 # django-rules: регистрация предикатов и правил доступа import procurement.rules # noqa: F401 diff --git a/backend/procurement/migrations/0015_procurementitemattachment.py b/backend/procurement/migrations/0015_procurementitemattachment.py new file mode 100644 index 00000000..b5f287d9 --- /dev/null +++ b/backend/procurement/migrations/0015_procurementitemattachment.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.4 on 2026-07-17 07:34 + +import django.db.models.deletion +import procurement.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('procurement', '0014_procurementsettings'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ProcurementItemAttachment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(max_length=500, upload_to=procurement.models.procurement_item_attachment_upload_to, verbose_name='Файл')), + ('file_name', models.TextField(verbose_name='Имя файла')), + ('file_size', models.BigIntegerField(default=0, verbose_name='Размер файла')), + ('mime_type', models.CharField(blank=True, max_length=255, verbose_name='MIME-тип')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='procurement.procurementitem', verbose_name='Позиция')), + ('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='procurement_item_attachments', to=settings.AUTH_USER_MODEL, verbose_name='Загрузил')), + ], + options={ + 'verbose_name': 'Вложение позиции закупки', + 'verbose_name_plural': 'Вложения позиций закупки', + 'ordering': ['created_at', 'id'], + }, + ), + ] diff --git a/backend/procurement/migrations/0016_procurementrequestactivity.py b/backend/procurement/migrations/0016_procurementrequestactivity.py new file mode 100644 index 00000000..03aa082c --- /dev/null +++ b/backend/procurement/migrations/0016_procurementrequestactivity.py @@ -0,0 +1,91 @@ +# Generated by Django 5.2.4 on 2026-07-17 10:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def backfill_procurement_activity(apps, schema_editor): + ProcurementRequest = apps.get_model("procurement", "ProcurementRequest") + Activity = apps.get_model("procurement", "ProcurementRequestActivity") + + activities = [] + activity_times = [] + for procurement_request in ProcurementRequest.objects.iterator(chunk_size=500): + entries = [ + ( + "created", + procurement_request.created_at, + procurement_request.requestor_id, + ), + ( + "submitted", + procurement_request.submitted_at, + procurement_request.requestor_id, + ), + ( + "started", + procurement_request.started_at, + procurement_request.executor_id, + ), + ( + "completed", + procurement_request.completed_at, + procurement_request.executor_id, + ), + ] + for action, created_at, actor_id in entries: + if created_at is None: + continue + activities.append( + Activity( + request_id=procurement_request.pk, + actor_id=actor_id, + action=action, + object_kind="request", + object_id=procurement_request.pk, + metadata={"backfilled": True}, + ) + ) + activity_times.append(created_at) + + if not activities: + return + Activity.objects.bulk_create(activities, batch_size=500) + for activity, created_at in zip(activities, activity_times): + activity.created_at = created_at + Activity.objects.bulk_update(activities, ["created_at"], batch_size=500) + + +class Migration(migrations.Migration): + + dependencies = [ + ('procurement', '0015_procurementitemattachment'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ProcurementRequestActivity', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(choices=[('created', 'Создал заявку'), ('updated', 'Изменил заявку'), ('submitted', 'Отправил на согласование'), ('stage_approved', 'Согласовал этап'), ('stage_rejected', 'Отклонил этап согласования'), ('approved', 'Заявка согласована'), ('started', 'Взял заявку в работу'), ('executor_reassigned', 'Сменил исполнителя'), ('completed', 'Завершил заявку'), ('rejected', 'Отклонил заявку'), ('cancelled', 'Отменил заявку'), ('item_updated', 'Изменил исполнение позиции'), ('all_items_received', 'Отметил все позиции полученными'), ('attachment_added', 'Добавил файл'), ('attachment_removed', 'Удалил файл'), ('comment_added', 'Добавил комментарий'), ('comment_removed', 'Удалил комментарий'), ('arrival_notified', 'Уведомил о поступлении')], max_length=32, verbose_name='Действие')), + ('object_kind', models.CharField(blank=True, choices=[('request', 'Заявка'), ('approval', 'Согласование'), ('item', 'Позиция'), ('attachment', 'Файл'), ('comment', 'Комментарий')], max_length=32, verbose_name='Тип объекта')), + ('object_id', models.PositiveBigIntegerField(blank=True, null=True, verbose_name='ID объекта')), + ('metadata', models.JSONField(blank=True, default=dict, verbose_name='Метаданные')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='procurement_activities', to=settings.AUTH_USER_MODEL, verbose_name='Инициатор')), + ('request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='procurement.procurementrequest', verbose_name='Заявка')), + ], + options={ + 'verbose_name': 'История заявки на закупку', + 'verbose_name_plural': 'История заявок на закупку', + 'ordering': ['-created_at', '-id'], + 'indexes': [models.Index(fields=['request', '-created_at'], name='procurement_request_4b22f4_idx')], + }, + ), + migrations.RunPython( + backfill_procurement_activity, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/backend/procurement/models.py b/backend/procurement/models.py index 58285824..be2713f8 100644 --- a/backend/procurement/models.py +++ b/backend/procurement/models.py @@ -308,6 +308,77 @@ class ProcurementRequest(models.Model): return new_status +class ProcurementActivityObjectKind(models.TextChoices): + REQUEST = "request", "Заявка" + APPROVAL = "approval", "Согласование" + ITEM = "item", "Позиция" + ATTACHMENT = "attachment", "Файл" + COMMENT = "comment", "Комментарий" + + +class ProcurementActivityAction(models.TextChoices): + CREATED = "created", "Создал заявку" + UPDATED = "updated", "Изменил заявку" + SUBMITTED = "submitted", "Отправил на согласование" + STAGE_APPROVED = "stage_approved", "Согласовал этап" + STAGE_REJECTED = "stage_rejected", "Отклонил этап согласования" + APPROVED = "approved", "Заявка согласована" + STARTED = "started", "Взял заявку в работу" + EXECUTOR_REASSIGNED = "executor_reassigned", "Сменил исполнителя" + COMPLETED = "completed", "Завершил заявку" + REJECTED = "rejected", "Отклонил заявку" + CANCELLED = "cancelled", "Отменил заявку" + ITEM_UPDATED = "item_updated", "Изменил исполнение позиции" + ALL_ITEMS_RECEIVED = "all_items_received", "Отметил все позиции полученными" + ATTACHMENT_ADDED = "attachment_added", "Добавил файл" + ATTACHMENT_REMOVED = "attachment_removed", "Удалил файл" + COMMENT_ADDED = "comment_added", "Добавил комментарий" + COMMENT_REMOVED = "comment_removed", "Удалил комментарий" + ARRIVAL_NOTIFIED = "arrival_notified", "Уведомил о поступлении" + + +class ProcurementRequestActivity(models.Model): + request = models.ForeignKey( + ProcurementRequest, + on_delete=models.CASCADE, + related_name="activities", + verbose_name="Заявка", + ) + actor = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="procurement_activities", + verbose_name="Инициатор", + ) + action = models.CharField( + "Действие", + max_length=32, + choices=ProcurementActivityAction.choices, + ) + object_kind = models.CharField( + "Тип объекта", + max_length=32, + choices=ProcurementActivityObjectKind.choices, + blank=True, + ) + object_id = models.PositiveBigIntegerField("ID объекта", null=True, blank=True) + metadata = models.JSONField("Метаданные", default=dict, blank=True) + created_at = models.DateTimeField("Создано", auto_now_add=True) + + class Meta: + verbose_name = "История заявки на закупку" + verbose_name_plural = "История заявок на закупку" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["request", "-created_at"]), + ] + + def __str__(self): + return f"{self.request} - {self.action}" + + class ProcurementRequestView(models.Model): """Персональная отметка просмотра заявки пользователем.""" @@ -513,6 +584,7 @@ class ProcurementItem(models.Model): {"received_quantity": "Не может быть больше количества позиции"} ) + @staticmethod def _mark_update_field(update_fields, field): if update_fields is not None: @@ -589,6 +661,48 @@ class ProcurementItem(models.Model): return result +def procurement_item_attachment_upload_to(instance, filename): + """Keep item attachments grouped while discarding client-side paths.""" + from pathlib import Path + + safe_name = Path(filename).name + return f"procurement/item_attachments/{instance.item_id}/{safe_name}" + + +class ProcurementItemAttachment(models.Model): + """Файл, прикреплённый к отдельной позиции заявки.""" + + item = models.ForeignKey( + ProcurementItem, + on_delete=models.CASCADE, + related_name="attachments", + verbose_name="Позиция", + ) + file = models.FileField( + "Файл", + upload_to=procurement_item_attachment_upload_to, + max_length=500, + ) + file_name = models.TextField("Имя файла") + file_size = models.BigIntegerField("Размер файла", default=0) + mime_type = models.CharField("MIME-тип", max_length=255, blank=True) + uploaded_by = models.ForeignKey( + User, + on_delete=models.PROTECT, + related_name="procurement_item_attachments", + verbose_name="Загрузил", + ) + created_at = models.DateTimeField("Создано", auto_now_add=True) + + class Meta: + verbose_name = "Вложение позиции закупки" + verbose_name_plural = "Вложения позиций закупки" + ordering = ["created_at", "id"] + + def __str__(self): + return self.file_name + + class ApprovalRoute(models.Model): """Правило получения согласующего для этапа с заданным приоритетом.""" diff --git a/backend/procurement/signals.py b/backend/procurement/signals.py new file mode 100644 index 00000000..16b5bf3c --- /dev/null +++ b/backend/procurement/signals.py @@ -0,0 +1,98 @@ +from django.db import transaction +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver + +from .activity import create_procurement_activity +from .constants import ApprovalStatus, ProcurementStatus +from .models import ( + Approval, + ProcurementActivityAction, + ProcurementActivityObjectKind, + ProcurementItemAttachment, + ProcurementRequest, +) + + +REQUEST_STATUS_ACTIONS = { + ProcurementStatus.PENDING: ProcurementActivityAction.SUBMITTED, + ProcurementStatus.APPROVED: ProcurementActivityAction.APPROVED, + ProcurementStatus.WAITING: ProcurementActivityAction.APPROVED, + ProcurementStatus.IN_PROGRESS: ProcurementActivityAction.STARTED, + ProcurementStatus.COMPLETED: ProcurementActivityAction.COMPLETED, + ProcurementStatus.REJECTED: ProcurementActivityAction.REJECTED, + ProcurementStatus.CANCELLED: ProcurementActivityAction.CANCELLED, +} + + +@receiver(post_save, sender=ProcurementRequest) +def record_procurement_request_activity(sender, instance, created, **kwargs): + actor = getattr(instance, "_notification_actor", None) + if created: + create_procurement_activity( + instance, + actor or instance.requestor, + ProcurementActivityAction.CREATED, + object_kind=ProcurementActivityObjectKind.REQUEST, + object_id=instance.pk, + metadata={"title": instance.title}, + ) + return + + old_status = getattr(instance, "_original_status", instance.status) + if old_status == instance.status: + return + + action = REQUEST_STATUS_ACTIONS.get(instance.status) + if action is None: + return + create_procurement_activity( + instance, + actor, + action, + object_kind=ProcurementActivityObjectKind.REQUEST, + object_id=instance.pk, + metadata={ + "old_status": old_status, + "new_status": instance.status, + "reason": getattr(instance, "cancellation_reason", ""), + }, + ) + + +@receiver(post_save, sender=Approval) +def record_procurement_approval_activity(sender, instance, created, **kwargs): + if created: + return + old_status = getattr(instance, "_original_approval_status", instance.status) + if old_status == instance.status: + return + action = { + ApprovalStatus.APPROVED: ProcurementActivityAction.STAGE_APPROVED, + ApprovalStatus.REJECTED: ProcurementActivityAction.STAGE_REJECTED, + }.get(instance.status) + if action is None: + return + create_procurement_activity( + instance.request, + instance.approver, + action, + object_kind=ProcurementActivityObjectKind.APPROVAL, + object_id=instance.pk, + metadata={ + "step_name": instance.step_name, + "priority": instance.priority, + "comment": instance.comment, + }, + ) + + +@receiver(post_delete, sender=ProcurementItemAttachment) +def delete_procurement_item_attachment_file(sender, instance, **kwargs): + if not instance.file or not instance.file.name: + return + + storage = instance.file.storage + name = instance.file.name + transaction.on_commit( + lambda: storage.delete(name) if storage.exists(name) else None + ) diff --git a/backend/requests_app/constants.py b/backend/requests_app/constants.py index 4ac697a5..e11d0077 100644 --- a/backend/requests_app/constants.py +++ b/backend/requests_app/constants.py @@ -2,7 +2,6 @@ from django.conf import settings -MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10 MB SAFE_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"] _ALLOWED_EXTS_DOTTED = { e if str(e).startswith(".") else f".{e}" diff --git a/backend/requests_app/migrations/0013_alter_request_attachment.py b/backend/requests_app/migrations/0013_alter_request_attachment.py new file mode 100644 index 00000000..fb90105c --- /dev/null +++ b/backend/requests_app/migrations/0013_alter_request_attachment.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.4 on 2026-07-16 15:09 + +import django.core.validators +import requests_app.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('requests_app', '0012_add_maternity_request_type'), + ] + + operations = [ + migrations.AlterField( + model_name='request', + name='attachment', + field=models.FileField(blank=True, null=True, upload_to='requests/attachments/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'jpg', 'jpeg', 'png']), requests_app.models.attachment_mime_validator], verbose_name='Вложение'), + ), + ] diff --git a/backend/requests_app/models.py b/backend/requests_app/models.py index 2edffac7..9fd7f43d 100644 --- a/backend/requests_app/models.py +++ b/backend/requests_app/models.py @@ -10,7 +10,6 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from .constants import ( - MAX_ATTACHMENT_SIZE, SAFE_EXTENSIONS, SAFE_MIME_TYPES, ) @@ -20,12 +19,8 @@ logger = logging.getLogger(__name__) def attachment_file_size(value): - """Ограничение размера вложения (10 МБ).""" - if value and getattr(value, "size", 0) > MAX_ATTACHMENT_SIZE: - mb = MAX_ATTACHMENT_SIZE // (1024 * 1024) - raise ValidationError( - _("Размер файла не должен превышать %(mb)s МБ."), params={"mb": mb} - ) + """Оставлено для совместимости со старыми миграциями.""" + return None def attachment_mime_validator(value): @@ -142,7 +137,6 @@ class Request(models.Model): null=True, validators=[ FileExtensionValidator(allowed_extensions=SAFE_EXTENSIONS), - attachment_file_size, attachment_mime_validator, ], ) diff --git a/backend/static/js/components/avatarCropperHandler.js b/backend/static/js/components/avatarCropperHandler.js index 92546c63..32252e81 100644 --- a/backend/static/js/components/avatarCropperHandler.js +++ b/backend/static/js/components/avatarCropperHandler.js @@ -64,7 +64,6 @@ const DEFAULT_CONFIG = { cancelButton: 'Отмена', errorTooSmall: 'Изображение слишком маленькое. Минимальный размер: ', errorInvalidFormat: 'Неподдерживаемый формат. Используйте JPG, PNG или GIF.', - errorFileSize: 'Файл слишком большой. Максимум 10 МБ.', } }; @@ -230,13 +229,6 @@ export class AvatarCropper { return; } - // Валидация размера (10 МБ) - if (file.size > 10 * 1024 * 1024) { - alert(this.config.texts.errorFileSize); - event.target.value = ''; - return; - } - this.currentFile = file; // Проверяем размер изображения diff --git a/backend/tasks/access.py b/backend/tasks/access.py index 3dd5baf4..c1a181b9 100644 --- a/backend/tasks/access.py +++ b/backend/tasks/access.py @@ -8,7 +8,7 @@ from requests_app.models import Request as EmployeeRequest from schedule.models import Event from scheduling.services import user_can_view_calendar -from .models import TaskBoard +from .models import TaskBoard, TaskBoardAccessScope def get_user_department_ids(user) -> list[int]: @@ -39,10 +39,15 @@ def task_board_access_q(user): department_ids = get_user_department_ids(user) q = Q(created_by=user) - q |= Q(members=user) + non_private_q = ~Q(access_scope=TaskBoardAccessScope.PRIVATE) + q |= non_private_q & Q(members=user) if department_ids: - q |= Q(departments__in=department_ids) - q |= Q(members__isnull=True, departments__isnull=True) + q |= non_private_q & Q(departments__in=department_ids) + q |= Q( + access_scope=TaskBoardAccessScope.ALL, + members__isnull=True, + departments__isnull=True, + ) return q @@ -53,13 +58,16 @@ def user_can_access_task_board(user, board: TaskBoard | None) -> bool: return True if board.created_by_id == user.id: return True + if board.access_scope == TaskBoardAccessScope.PRIVATE: + return False if board.members.filter(id=user.id).exists(): return True department_ids = get_user_department_ids(user) if department_ids and board.departments.filter(id__in=department_ids).exists(): return True - return ( - not board.members.exists() + return bool( + board.access_scope == TaskBoardAccessScope.ALL + and not board.members.exists() and not board.departments.exists() ) diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index 4f5ced13..2f1f281f 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -3,8 +3,11 @@ from django.contrib import admin from .models import ( Task, TaskActivity, + TaskAttachment, TaskBoard, + TaskChecklistItem, TaskColumn, + TaskExternalLink, TaskLabel, TaskLinkedObject, TaskUserSettings, @@ -25,8 +28,14 @@ class TaskLabelInline(admin.TabularInline): @admin.register(TaskBoard) class TaskBoardAdmin(admin.ModelAdmin): - list_display = ["name", "created_by", "is_archived", "created_at"] - list_filter = ["is_archived", "created_at"] + list_display = [ + "name", + "created_by", + "access_scope", + "is_archived", + "created_at", + ] + list_filter = ["access_scope", "is_archived", "created_at"] search_fields = ["name", "description"] filter_horizontal = ["members", "departments"] inlines = [TaskColumnInline, TaskLabelInline] @@ -62,6 +71,29 @@ class TaskAdmin(admin.ModelAdmin): filter_horizontal = ["labels"] +@admin.register(TaskAttachment) +class TaskAttachmentAdmin(admin.ModelAdmin): + list_display = ["file_name", "task", "uploaded_by", "file_size", "created_at"] + list_filter = ["created_at"] + search_fields = ["file_name", "task__title", "uploaded_by__email"] + autocomplete_fields = ["task", "uploaded_by"] + + +@admin.register(TaskChecklistItem) +class TaskChecklistItemAdmin(admin.ModelAdmin): + list_display = [ + "title", + "task", + "position", + "is_completed", + "completed_by", + "updated_at", + ] + list_filter = ["is_completed", "created_at", "updated_at"] + search_fields = ["title", "task__title", "created_by__email"] + autocomplete_fields = ["task", "created_by", "completed_by"] + + @admin.register(TaskLinkedObject) class TaskLinkedObjectAdmin(admin.ModelAdmin): list_display = ["task", "kind", "content_type", "object_id", "created_by", "created_at"] @@ -69,6 +101,14 @@ class TaskLinkedObjectAdmin(admin.ModelAdmin): search_fields = ["task__title", "created_by__email", "object_id"] +@admin.register(TaskExternalLink) +class TaskExternalLinkAdmin(admin.ModelAdmin): + list_display = ["title", "url", "task", "created_by", "created_at"] + list_filter = ["created_at"] + search_fields = ["title", "url", "task__title", "created_by__email"] + autocomplete_fields = ["task", "created_by"] + + @admin.register(TaskActivity) class TaskActivityAdmin(admin.ModelAdmin): list_display = ["task", "action", "actor", "object_kind", "object_id", "created_at"] diff --git a/backend/tasks/apps.py b/backend/tasks/apps.py index ea933ceb..02e6ff83 100644 --- a/backend/tasks/apps.py +++ b/backend/tasks/apps.py @@ -8,3 +8,4 @@ class TasksConfig(AppConfig): def ready(self): import tasks.notifications.signals # noqa: F401 + import tasks.signals # noqa: F401 diff --git a/backend/tasks/migrations/0013_alter_taskactivity_action_taskattachment.py b/backend/tasks/migrations/0013_alter_taskactivity_action_taskattachment.py new file mode 100644 index 00000000..262bdecb --- /dev/null +++ b/backend/tasks/migrations/0013_alter_taskactivity_action_taskattachment.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.4 on 2026-07-16 14:54 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0012_taskusersettings'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='taskactivity', + name='action', + field=models.CharField(choices=[('created', 'Создал задачу'), ('updated', 'Обновил задачу'), ('moved', 'Переместил задачу'), ('linked', 'Связал объект'), ('unlinked', 'Убрал связь'), ('attachment_added', 'Добавил файл'), ('attachment_removed', 'Удалил файл')], max_length=32, verbose_name='Действие'), + ), + migrations.CreateModel( + name='TaskAttachment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to='task_attachments/%Y/%m/%d/', verbose_name='Файл')), + ('file_name', models.CharField(max_length=255, verbose_name='Название файла')), + ('file_size', models.PositiveBigIntegerField(verbose_name='Размер файла (байты)')), + ('mime_type', models.CharField(blank=True, max_length=255, verbose_name='MIME-тип')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Загружено')), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='tasks.task', verbose_name='Задача')), + ('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='uploaded_task_attachments', to=settings.AUTH_USER_MODEL, verbose_name='Загрузил')), + ], + options={ + 'verbose_name': 'Вложение задачи', + 'verbose_name_plural': 'Вложения задач', + 'ordering': ['created_at', 'id'], + 'indexes': [models.Index(fields=['task', 'created_at'], name='tasks_taska_task_id_3aa34c_idx')], + }, + ), + ] diff --git a/backend/tasks/migrations/0014_alter_taskactivity_object_kind_and_more.py b/backend/tasks/migrations/0014_alter_taskactivity_object_kind_and_more.py new file mode 100644 index 00000000..0ade891a --- /dev/null +++ b/backend/tasks/migrations/0014_alter_taskactivity_object_kind_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.4 on 2026-07-16 15:52 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0013_alter_taskactivity_action_taskattachment'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='taskactivity', + name='object_kind', + field=models.CharField(blank=True, choices=[('post', 'Новость'), ('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости'), ('external_link', 'Внешняя ссылка')], max_length=32, verbose_name='Тип объекта'), + ), + migrations.CreateModel( + name='TaskExternalLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.URLField(max_length=2048, verbose_name='Ссылка')), + ('title', models.CharField(blank=True, max_length=255, verbose_name='Название')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_task_external_links', to=settings.AUTH_USER_MODEL, verbose_name='Добавил')), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_links', to='tasks.task', verbose_name='Задача')), + ], + options={ + 'verbose_name': 'Внешняя ссылка задачи', + 'verbose_name_plural': 'Внешние ссылки задач', + 'ordering': ['-created_at', '-id'], + 'indexes': [models.Index(fields=['task', 'created_at'], name='tasks_taske_task_id_d49775_idx')], + 'constraints': [models.UniqueConstraint(fields=('task', 'url'), name='uniq_task_external_link')], + }, + ), + ] diff --git a/backend/tasks/migrations/0015_alter_taskactivity_action_and_more.py b/backend/tasks/migrations/0015_alter_taskactivity_action_and_more.py new file mode 100644 index 00000000..56e61451 --- /dev/null +++ b/backend/tasks/migrations/0015_alter_taskactivity_action_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.4 on 2026-07-16 17:55 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0014_alter_taskactivity_object_kind_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='taskactivity', + name='action', + field=models.CharField(choices=[('created', 'Создал задачу'), ('updated', 'Обновил задачу'), ('moved', 'Переместил задачу'), ('linked', 'Связал объект'), ('unlinked', 'Убрал связь'), ('attachment_added', 'Добавил файл'), ('attachment_removed', 'Удалил файл'), ('checklist_item_added', 'Добавил пункт чек-листа'), ('checklist_item_updated', 'Изменил пункт чек-листа'), ('checklist_item_completed', 'Выполнил пункт чек-листа'), ('checklist_item_reopened', 'Вернул пункт чек-листа в работу'), ('checklist_item_removed', 'Удалил пункт чек-листа')], max_length=32, verbose_name='Действие'), + ), + migrations.AlterField( + model_name='taskactivity', + name='object_kind', + field=models.CharField(blank=True, choices=[('post', 'Новость'), ('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости'), ('external_link', 'Внешняя ссылка'), ('checklist_item', 'Пункт чек-листа')], max_length=32, verbose_name='Тип объекта'), + ), + migrations.CreateModel( + name='TaskChecklistItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=500, verbose_name='Пункт')), + ('position', models.PositiveIntegerField(default=0, verbose_name='Позиция')), + ('is_completed', models.BooleanField(default=False, verbose_name='Выполнен')), + ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Выполнен')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), + ('completed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='completed_task_checklist_items', to=settings.AUTH_USER_MODEL, verbose_name='Отметил выполненным')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_task_checklist_items', to=settings.AUTH_USER_MODEL, verbose_name='Создал')), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checklist_items', to='tasks.task', verbose_name='Задача')), + ], + options={ + 'verbose_name': 'Пункт чек-листа задачи', + 'verbose_name_plural': 'Пункты чек-листа задач', + 'ordering': ['position', 'id'], + 'indexes': [models.Index(fields=['task', 'position'], name='tasks_taskc_task_id_0eaca5_idx'), models.Index(fields=['task', 'is_completed'], name='tasks_taskc_task_id_950de8_idx')], + }, + ), + ] diff --git a/backend/tasks/migrations/0016_taskboard_access_scope.py b/backend/tasks/migrations/0016_taskboard_access_scope.py new file mode 100644 index 00000000..3289ac0d --- /dev/null +++ b/backend/tasks/migrations/0016_taskboard_access_scope.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.4 on 2026-07-17 05:29 + +from django.db import migrations, models + + +def preserve_restricted_boards(apps, schema_editor): + TaskBoard = apps.get_model("tasks", "TaskBoard") + restricted_ids = TaskBoard.objects.filter( + models.Q(members__isnull=False) | models.Q(departments__isnull=False) + ).values_list("id", flat=True) + TaskBoard.objects.filter(id__in=restricted_ids).update(access_scope="restricted") + + +def restore_legacy_access(apps, schema_editor): + TaskBoard = apps.get_model("tasks", "TaskBoard") + TaskBoard.objects.filter(access_scope="restricted").update(access_scope="all") + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0015_alter_taskactivity_action_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="taskboard", + name="access_scope", + field=models.CharField( + choices=[ + ("all", "Для всех"), + ("private", "Для себя"), + ("restricted", "Выборочно"), + ], + default="all", + max_length=16, + verbose_name="Доступ", + ), + ), + migrations.RunPython( + preserve_restricted_boards, + restore_legacy_access, + ), + ] diff --git a/backend/tasks/migrations/0017_task_comment_activity.py b/backend/tasks/migrations/0017_task_comment_activity.py new file mode 100644 index 00000000..2559b552 --- /dev/null +++ b/backend/tasks/migrations/0017_task_comment_activity.py @@ -0,0 +1,59 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0016_taskboard_access_scope"), + ] + + operations = [ + migrations.AlterField( + model_name="taskactivity", + name="action", + field=models.CharField( + choices=[ + ("created", "Создал задачу"), + ("updated", "Обновил задачу"), + ("moved", "Переместил задачу"), + ("linked", "Связал объект"), + ("unlinked", "Убрал связь"), + ("attachment_added", "Добавил файл"), + ("attachment_removed", "Удалил файл"), + ("checklist_item_added", "Добавил пункт чек-листа"), + ("checklist_item_updated", "Изменил пункт чек-листа"), + ("checklist_item_completed", "Выполнил пункт чек-листа"), + ("checklist_item_reopened", "Вернул пункт чек-листа в работу"), + ("checklist_item_removed", "Удалил пункт чек-листа"), + ("comment_added", "Добавил комментарий"), + ("comment_edited", "Изменил комментарий"), + ("comment_removed", "Удалил комментарий"), + ], + max_length=32, + verbose_name="Действие", + ), + ), + migrations.AlterField( + model_name="taskactivity", + name="object_kind", + field=models.CharField( + blank=True, + choices=[ + ("post", "Новость"), + ("message", "Сообщение"), + ("calendar_event", "Календарное событие"), + ("document", "Документ"), + ("request", "Заявление"), + ("procurement_request", "Заявка на закупку"), + ("employee", "Сотрудник"), + ("guest", "Гость"), + ("guest_visit", "Заявка на гостевой визит"), + ("attendance_record", "Запись посещаемости"), + ("external_link", "Внешняя ссылка"), + ("checklist_item", "Пункт чек-листа"), + ("comment", "Комментарий"), + ], + max_length=32, + verbose_name="Тип объекта", + ), + ), + ] diff --git a/backend/tasks/migrations/0018_task_claim_activity.py b/backend/tasks/migrations/0018_task_claim_activity.py new file mode 100644 index 00000000..ab03193f --- /dev/null +++ b/backend/tasks/migrations/0018_task_claim_activity.py @@ -0,0 +1,36 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0017_task_comment_activity"), + ] + + operations = [ + migrations.AlterField( + model_name="taskactivity", + name="action", + field=models.CharField( + choices=[ + ("created", "Создал задачу"), + ("updated", "Обновил задачу"), + ("claimed", "Взял задачу в работу"), + ("moved", "Переместил задачу"), + ("linked", "Связал объект"), + ("unlinked", "Убрал связь"), + ("attachment_added", "Добавил файл"), + ("attachment_removed", "Удалил файл"), + ("checklist_item_added", "Добавил пункт чек-листа"), + ("checklist_item_updated", "Изменил пункт чек-листа"), + ("checklist_item_completed", "Выполнил пункт чек-листа"), + ("checklist_item_reopened", "Вернул пункт чек-листа в работу"), + ("checklist_item_removed", "Удалил пункт чек-листа"), + ("comment_added", "Добавил комментарий"), + ("comment_edited", "Изменил комментарий"), + ("comment_removed", "Удалил комментарий"), + ], + max_length=32, + verbose_name="Действие", + ), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 596d2948..c9f088d0 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -13,6 +13,12 @@ class TaskPriority(models.TextChoices): CRITICAL = "critical", "Критический" +class TaskBoardAccessScope(models.TextChoices): + ALL = "all", "Для всех" + PRIVATE = "private", "Для себя" + RESTRICTED = "restricted", "Выборочно" + + class TaskBoard(models.Model): name = models.CharField("Название", max_length=255) description = models.TextField("Описание", blank=True) @@ -34,6 +40,12 @@ class TaskBoard(models.Model): related_name="task_boards", verbose_name="Отделы", ) + access_scope = models.CharField( + "Доступ", + max_length=16, + choices=TaskBoardAccessScope.choices, + default=TaskBoardAccessScope.ALL, + ) is_archived = models.BooleanField("Архивная", default=False) created_at = models.DateTimeField("Создано", auto_now_add=True) updated_at = models.DateTimeField("Обновлено", auto_now=True) @@ -199,6 +211,79 @@ class Task(models.Model): super().save(*args, **kwargs) +class TaskAttachment(models.Model): + task = models.ForeignKey( + Task, + on_delete=models.CASCADE, + related_name="attachments", + verbose_name="Задача", + ) + file = models.FileField( + "Файл", + upload_to="task_attachments/%Y/%m/%d/", + ) + file_name = models.CharField("Название файла", max_length=255) + file_size = models.PositiveBigIntegerField("Размер файла (байты)") + mime_type = models.CharField("MIME-тип", max_length=255, blank=True) + uploaded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="uploaded_task_attachments", + verbose_name="Загрузил", + ) + created_at = models.DateTimeField("Загружено", auto_now_add=True) + + class Meta: + verbose_name = "Вложение задачи" + verbose_name_plural = "Вложения задач" + ordering = ["created_at", "id"] + indexes = [models.Index(fields=["task", "created_at"])] + + def __str__(self): + return f"{self.task}: {self.file_name}" + + +class TaskChecklistItem(models.Model): + task = models.ForeignKey( + Task, + on_delete=models.CASCADE, + related_name="checklist_items", + verbose_name="Задача", + ) + title = models.CharField("Пункт", max_length=500) + position = models.PositiveIntegerField("Позиция", default=0) + is_completed = models.BooleanField("Выполнен", default=False) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="created_task_checklist_items", + verbose_name="Создал", + ) + completed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="completed_task_checklist_items", + verbose_name="Отметил выполненным", + ) + completed_at = models.DateTimeField("Выполнен", null=True, blank=True) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Пункт чек-листа задачи" + verbose_name_plural = "Пункты чек-листа задач" + ordering = ["position", "id"] + indexes = [ + models.Index(fields=["task", "position"]), + models.Index(fields=["task", "is_completed"]), + ] + + def __str__(self): + return f"{self.task}: {self.title}" + + class TaskLinkedObjectKind(models.TextChoices): POST = "post", "Новость" MESSAGE = "message", "Сообщение" @@ -212,12 +297,45 @@ class TaskLinkedObjectKind(models.TextChoices): ATTENDANCE_RECORD = "attendance_record", "Запись посещаемости" +class TaskActivityObjectKind(models.TextChoices): + POST = "post", "Новость" + MESSAGE = "message", "Сообщение" + CALENDAR_EVENT = "calendar_event", "Календарное событие" + DOCUMENT = "document", "Документ" + REQUEST = "request", "Заявление" + PROCUREMENT_REQUEST = "procurement_request", "Заявка на закупку" + EMPLOYEE = "employee", "Сотрудник" + GUEST = "guest", "Гость" + GUEST_VISIT = "guest_visit", "Заявка на гостевой визит" + ATTENDANCE_RECORD = "attendance_record", "Запись посещаемости" + EXTERNAL_LINK = "external_link", "Внешняя ссылка" + CHECKLIST_ITEM = "checklist_item", "Пункт чек-листа" + COMMENT = "comment", "Комментарий" + + class TaskActivityAction(models.TextChoices): CREATED = "created", "Создал задачу" UPDATED = "updated", "Обновил задачу" + CLAIMED = "claimed", "Взял задачу в работу" MOVED = "moved", "Переместил задачу" LINKED = "linked", "Связал объект" UNLINKED = "unlinked", "Убрал связь" + ATTACHMENT_ADDED = "attachment_added", "Добавил файл" + ATTACHMENT_REMOVED = "attachment_removed", "Удалил файл" + CHECKLIST_ITEM_ADDED = "checklist_item_added", "Добавил пункт чек-листа" + CHECKLIST_ITEM_UPDATED = "checklist_item_updated", "Изменил пункт чек-листа" + CHECKLIST_ITEM_COMPLETED = ( + "checklist_item_completed", + "Выполнил пункт чек-листа", + ) + CHECKLIST_ITEM_REOPENED = ( + "checklist_item_reopened", + "Вернул пункт чек-листа в работу", + ) + CHECKLIST_ITEM_REMOVED = "checklist_item_removed", "Удалил пункт чек-листа" + COMMENT_ADDED = "comment_added", "Добавил комментарий" + COMMENT_EDITED = "comment_edited", "Изменил комментарий" + COMMENT_REMOVED = "comment_removed", "Удалил комментарий" class TaskActivity(models.Model): @@ -243,7 +361,7 @@ class TaskActivity(models.Model): object_kind = models.CharField( "Тип объекта", max_length=32, - choices=TaskLinkedObjectKind.choices, + choices=TaskActivityObjectKind.choices, blank=True, ) object_id = models.PositiveBigIntegerField("ID объекта", null=True, blank=True) @@ -307,3 +425,36 @@ class TaskLinkedObject(models.Model): def __str__(self): return f"{self.task} -> {self.kind}:{self.object_id}" + + +class TaskExternalLink(models.Model): + task = models.ForeignKey( + Task, + on_delete=models.CASCADE, + related_name="external_links", + verbose_name="Задача", + ) + url = models.URLField("Ссылка", max_length=2048) + title = models.CharField("Название", max_length=255, blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="created_task_external_links", + verbose_name="Добавил", + ) + created_at = models.DateTimeField("Создано", auto_now_add=True) + + class Meta: + verbose_name = "Внешняя ссылка задачи" + verbose_name_plural = "Внешние ссылки задач" + ordering = ["-created_at", "-id"] + constraints = [ + models.UniqueConstraint( + fields=["task", "url"], + name="uniq_task_external_link", + ) + ] + indexes = [models.Index(fields=["task", "created_at"])] + + def __str__(self): + return f"{self.task} -> {self.title or self.url}" diff --git a/backend/tasks/realtime.py b/backend/tasks/realtime.py index fccecee3..7a84a966 100644 --- a/backend/tasks/realtime.py +++ b/backend/tasks/realtime.py @@ -9,7 +9,7 @@ from django.db.models import Q from employees.models import Department, EmployeeDepartment, RoleAssignment -from .models import TaskBoard +from .models import TaskBoard, TaskBoardAccessScope logger = logging.getLogger(__name__) @@ -20,16 +20,27 @@ def get_task_board_recipient_ids(board: TaskBoard) -> set[int]: User = get_user_model() admin_q = Q(is_staff=True) | Q(is_superuser=True) + is_private = board.access_scope == TaskBoardAccessScope.PRIVATE - if not board.members.exists() and not board.departments.exists(): + if ( + not is_private + and board.access_scope == TaskBoardAccessScope.ALL + and not board.members.exists() + and not board.departments.exists() + ): return set( User.objects.filter(is_active=True) .values_list("id", flat=True) ) - department_ids = list(board.departments.values_list("id", flat=True)) + department_ids = ( + [] + if is_private + else list(board.departments.values_list("id", flat=True)) + ) recipient_ids = {board.created_by_id} - recipient_ids.update(board.members.values_list("id", flat=True)) + if not is_private: + recipient_ids.update(board.members.values_list("id", flat=True)) if department_ids: recipient_ids.update( diff --git a/backend/tasks/signals.py b/backend/tasks/signals.py new file mode 100644 index 00000000..fbd7199a --- /dev/null +++ b/backend/tasks/signals.py @@ -0,0 +1,15 @@ +from django.db import transaction +from django.db.models.signals import post_delete +from django.dispatch import receiver + +from .models import TaskAttachment + + +@receiver(post_delete, sender=TaskAttachment) +def delete_task_attachment_file(sender, instance, **kwargs): + if not instance.file or not instance.file.name: + return + + storage = instance.file.storage + name = instance.file.name + transaction.on_commit(lambda: storage.delete(name) if storage.exists(name) else None) diff --git a/backend/tests/api/v1/documents/test_documents_api.py b/backend/tests/api/v1/documents/test_documents_api.py index 0326b30d..e5729e23 100644 --- a/backend/tests/api/v1/documents/test_documents_api.py +++ b/backend/tests/api/v1/documents/test_documents_api.py @@ -173,6 +173,15 @@ def api_urls(): def _acknowledgements(pk: int) -> str: return reverse("api:v1:documents-acknowledgements", args=[pk]) + def _comments(pk: int) -> str: + return reverse("api:v1:documents-comments", args=[pk]) + + def _comment_detail(pk: int, comment_id: int) -> str: + return reverse( + "api:v1:documents-document-comment", + args=[pk, comment_id], + ) + def _folder_detail(pk: int) -> str: return reverse("api:v1:folders-detail", args=[pk]) @@ -185,6 +194,8 @@ def api_urls(): "detail": _detail, "ack": _ack, "acknowledgements": _acknowledgements, + "comments": _comments, + "comment_detail": _comment_detail, "folder_detail": _folder_detail, "folder_archive": _folder_archive, } @@ -1038,10 +1049,6 @@ class TestDocumentNotifications: def fake_notify_send(**kwargs): calls.append(kwargs) - monkeypatch.setattr( - "documents.notifications.handlers.notify.send", - fake_notify_send, - ) author = make_user("required-document-author@example.com") viewer = make_user("required-document-viewer@example.com") doc = make_document( @@ -1049,6 +1056,11 @@ class TestDocumentNotifications: uploaded_by=author, sent_to_all=False, acknowledgement_required=True, + recipients=[viewer], + ) + monkeypatch.setattr( + "documents.notifications.handlers.notify.send", + fake_notify_send, ) notify_document_ready(doc, viewer) @@ -1208,6 +1220,197 @@ class TestAcknowledge: user=viewer, ).exists() + def test_new_document_defaults_to_all_access_and_all_acknowledgement( + self, auth_client, make_user, api_urls + ): + """Новый документ без настроек доступен всем и требует ознакомления от всех.""" + author = make_user("default-audience-author@example.com") + + response = auth_client(author).post( + api_urls["list"], + {"title": "Общий регламент"}, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED, response.content + document = Document.objects.get(pk=response.json()["id"]) + assert document.sent_to_all is True + assert document.acknowledgement_required is True + assert document.acknowledgement_for_all is True + assert response.json()["acknowledgement_required_for_user"] is True + + def test_selective_acknowledgement_is_independent_from_access( + self, auth_client, make_user, api_urls + ): + """Доступный документ подтверждают только сотрудники из отдельной аудитории.""" + author = make_user("selective-author@example.com") + required_user = make_user("selective-required@example.com") + access_only_user = make_user("selective-access-only@example.com") + + response = auth_client(author).post( + api_urls["list"], + { + "title": "Выборочное ознакомление", + "sent_to_all": False, + "recipient_ids": [required_user.id, access_only_user.id], + "acknowledgement_required": True, + "acknowledgement_for_all": False, + "acknowledgement_recipient_ids": [required_user.id], + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED, response.content + document_id = response.json()["id"] + assert [ + recipient["id"] + for recipient in response.json()["acknowledgement_recipients"] + ] == [required_user.id] + + access_only_detail = auth_client(access_only_user).get( + api_urls["detail"](document_id) + ) + assert access_only_detail.status_code == status.HTTP_200_OK + assert access_only_detail.json()["acknowledgement_required_for_user"] is False + assert access_only_detail.json()["acknowledged_count"] == 0 + assert access_only_detail.json()["acknowledgement_total"] == 1 + rejected = auth_client(access_only_user).post(api_urls["ack"](document_id), {}) + assert rejected.status_code == status.HTTP_400_BAD_REQUEST + + required_detail = auth_client(required_user).get(api_urls["detail"](document_id)) + assert required_detail.json()["acknowledgement_required_for_user"] is True + acknowledged = auth_client(required_user).post(api_urls["ack"](document_id), {}) + assert acknowledged.status_code == status.HTTP_200_OK + refreshed_detail = auth_client(required_user).get( + api_urls["detail"](document_id) + ) + assert refreshed_detail.json()["acknowledged_count"] == 1 + assert refreshed_detail.json()["acknowledgement_total"] == 1 + + report = auth_client(access_only_user).get( + api_urls["acknowledgements"](document_id) + ) + assert report.status_code == status.HTTP_200_OK + assert report.json()["counts"] == { + "acknowledged": 1, + "unacknowledged": 0, + "total": 1, + } + assert report.json()["acknowledged"][0]["id"] == required_user.id + + def test_selective_acknowledgement_cannot_exceed_document_access( + self, auth_client, make_user, api_urls + ): + """Нельзя обязать ознакомиться сотрудника без доступа к документу.""" + author = make_user("invalid-audience-author@example.com") + recipient = make_user("invalid-audience-recipient@example.com") + outsider = make_user("invalid-audience-outsider@example.com") + + response = auth_client(author).post( + api_urls["list"], + { + "title": "Некорректная аудитория", + "sent_to_all": False, + "recipient_ids": [recipient.id], + "acknowledgement_required": True, + "acknowledgement_for_all": False, + "acknowledgement_recipient_ids": [outsider.id], + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "acknowledgement_recipient_ids" in response.json() + + def test_edit_replaces_acknowledgement_audience_without_changing_access( + self, auth_client, make_user, api_urls + ): + """Редактирование меняет только отдельную аудиторию ознакомления.""" + author = make_user("edit-audience-author@example.com") + grant_perms(author, "change_document") + first_recipient = make_user("edit-audience-first@example.com") + second_recipient = make_user("edit-audience-second@example.com") + document = make_document( + uploaded_by=author, + sent_to_all=False, + acknowledgement_required=True, + recipients=[first_recipient, second_recipient], + ) + document.acknowledgement_for_all = False + document.save(update_fields=["acknowledgement_for_all"]) + document.acknowledgement_recipients.set([first_recipient]) + + response = auth_client(author).patch( + api_urls["detail"](document.pk), + {"acknowledgement_recipient_ids": [second_recipient.id]}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK, response.content + document.refresh_from_db() + assert set(document.recipients.values_list("id", flat=True)) == { + first_recipient.id, + second_recipient.id, + } + assert list( + document.acknowledgement_recipients.values_list("id", flat=True) + ) == [second_recipient.id] + assert [ + recipient["id"] + for recipient in response.json()["acknowledgement_recipients"] + ] == [second_recipient.id] + + def test_document_comments_crud_and_counter( + self, auth_client, make_user, api_urls + ): + """Комментарии документа работают через Communications и попадают в счётчик.""" + author = make_user("document-comment-author@example.com") + other_user = make_user("document-comment-other@example.com") + document = make_document( + uploaded_by=author, + sent_to_all=True, + ) + client = auth_client(author) + + empty_detail = client.get(api_urls["detail"](document.pk)) + assert empty_detail.json()["comments_count"] == 0 + + created = client.post( + api_urls["comments"](document.pk), + {"text": "Первый комментарий"}, + format="json", + ) + assert created.status_code == status.HTTP_201_CREATED, created.content + comment_id = created.json()["id"] + assert created.json()["text"] == "Первый комментарий" + + detail = client.get(api_urls["detail"](document.pk)) + assert detail.json()["comments_count"] == 1 + + edited = client.patch( + api_urls["comment_detail"](document.pk, comment_id), + {"text": "Исправленный комментарий"}, + format="json", + ) + assert edited.status_code == status.HTTP_200_OK + assert edited.json()["text"] == "Исправленный комментарий" + assert edited.json()["is_edited"] is True + + forbidden = auth_client(other_user).patch( + api_urls["comment_detail"](document.pk, comment_id), + {"text": "Чужая правка"}, + format="json", + ) + assert forbidden.status_code == status.HTTP_403_FORBIDDEN + + deleted = client.delete( + api_urls["comment_detail"](document.pk, comment_id) + ) + assert deleted.status_code == status.HTTP_204_NO_CONTENT + assert client.get(api_urls["detail"](document.pk)).json()[ + "comments_count" + ] == 0 + class TestAcknowledgementsReport: """Ведомость ознакомлений доступна всем, кому доступен документ.""" @@ -1379,10 +1582,10 @@ class TestErrorsAndEdgeCases: assert client.get(api_urls["list"]).status_code == 200 assert client.get(api_urls["detail"](d.pk)).status_code == 404 - def test_big_file_over_limit_if_configured( - self, auth_client, make_user, api_urls, settings + def test_big_file_is_accepted( + self, auth_client, make_user, api_urls ): - """Большой файл > лимита → ожидаем 413 или 400 (если лимит включён); иначе 201.""" + """Размер файла сам по себе не блокирует загрузку.""" uploader = make_user("uploader@example.com", staff=True) client = auth_client(uploader) @@ -1392,10 +1595,7 @@ class TestErrorsAndEdgeCases: {"title": "Big", "file": big, "sent_to_all": True}, format="multipart", ) - # django-filer не проверяет DATA_UPLOAD_MAX_MEMORY_SIZE при загрузке - # Лимит применяется на уровне Django request parserа, но во время тестов это может не работать - # Просто проверяем, что запрос корректно обработан - assert r.status_code in (200, 201, 400, 413) + assert r.status_code == 201 # -------------------- I. Производительность/фильтры -------------------- diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 91c00cd3..90396c57 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -9,6 +9,7 @@ from unittest.mock import patch import pytest from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType +from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from django.utils.dateparse import parse_datetime from django.utils import timezone @@ -37,8 +38,11 @@ from procurement.models import ( Approval, ApprovalRoute, Budget, + ProcurementActivityAction, ProcurementItem, + ProcurementItemAttachment, ProcurementRequest, + ProcurementRequestActivity, ProcurementSettings, ) from procurement.notifications.handlers import notify_new_request @@ -605,6 +609,10 @@ class TestProcurementRequestCreate: assert response.data['title'] == 'Закупка мониторов' assert response.data['requestor'] == user.id assert response.data['status'] == ProcurementStatus.DRAFT + assert [item['id'] for item in response.data['items']] == list( + ProcurementRequest.objects.get(pk=response.data['id']) + .items.values_list('id', flat=True) + ) # Проверяем что позиции созданы request_id = response.data['id'] @@ -834,6 +842,17 @@ class TestProcurementRequestComments: after = api_client.get(list_url) after_item = next(item for item in after.data['results'] if item['id'] == procurement_request.id) assert after_item['comments_count'] == 0 + assert list( + procurement_request.activities.filter( + action__in=[ + ProcurementActivityAction.COMMENT_ADDED, + ProcurementActivityAction.COMMENT_REMOVED, + ] + ).values_list("action", flat=True) + ) == [ + ProcurementActivityAction.COMMENT_REMOVED, + ProcurementActivityAction.COMMENT_ADDED, + ] class TestProcurementItemComments: @@ -912,6 +931,18 @@ class TestProcurementItemComments: if item['id'] == procurement_item.id ) assert after_item['comments_count'] == 0 + comment_activities = procurement_item.request.activities.filter( + action__in=[ + ProcurementActivityAction.COMMENT_ADDED, + ProcurementActivityAction.COMMENT_REMOVED, + ], + object_id=comment_id, + ) + assert list(comment_activities.values_list("action", flat=True)) == [ + ProcurementActivityAction.COMMENT_REMOVED, + ProcurementActivityAction.COMMENT_ADDED, + ] + assert comment_activities.first().metadata["item_id"] == procurement_item.id def test_outsider_cannot_comment_item( self, api_client, procurement_item @@ -992,6 +1023,123 @@ class TestProcurementItemComments: assert response.data['item'] == item.id +class TestProcurementItemAttachments: + def test_archive_attachment_lifecycle( + self, + api_client, + user, + procurement_item, + settings, + tmp_path, + ): + settings.MEDIA_ROOT = tmp_path + api_client.force_authenticate(user=user) + attachments_url = reverse( + 'api:v1:procurement:procurementitem-attachments', + kwargs={'pk': procurement_item.id}, + ) + + archive_content = b'PK\x03\x04procurement archive content' + response = api_client.post( + attachments_url, + { + 'files': SimpleUploadedFile( + 'specification.zip', + archive_content, + content_type='application/zip', + ), + }, + format='multipart', + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data[0]['file_name'] == 'specification.zip' + assert response.data[0]['mime_type'] == 'application/zip' + attachment_id = response.data[0]['id'] + assert ProcurementItemAttachment.objects.filter(pk=attachment_id).exists() + + request_detail = api_client.get( + reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': procurement_item.request_id}, + ) + ) + detail_item = next( + item for item in request_detail.data['items'] + if item['id'] == procurement_item.id + ) + assert detail_item['attachments_count'] == 1 + assert detail_item['attachments'][0]['id'] == attachment_id + + attachment_url = reverse( + 'api:v1:procurement:procurementitem-attachment-detail', + kwargs={ + 'pk': procurement_item.id, + 'attachment_id': attachment_id, + }, + ) + download_response = api_client.get(attachment_url) + assert download_response.status_code == status.HTTP_200_OK + assert b''.join(download_response.streaming_content) == archive_content + + delete_response = api_client.delete(attachment_url) + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + assert not ProcurementItemAttachment.objects.filter(pk=attachment_id).exists() + attachment_activities = procurement_item.request.activities.filter( + action__in=[ + ProcurementActivityAction.ATTACHMENT_ADDED, + ProcurementActivityAction.ATTACHMENT_REMOVED, + ], + object_id=attachment_id, + ) + assert list(attachment_activities.values_list("action", flat=True)) == [ + ProcurementActivityAction.ATTACHMENT_REMOVED, + ProcurementActivityAction.ATTACHMENT_ADDED, + ] + assert attachment_activities.first().metadata["file_name"] == "specification.zip" + + def test_outsider_cannot_access_attachment( + self, + api_client, + procurement_item, + user, + settings, + tmp_path, + ): + settings.MEDIA_ROOT = tmp_path + attachment = ProcurementItemAttachment.objects.create( + item=procurement_item, + file=SimpleUploadedFile('private.txt', b'private'), + file_name='private.txt', + file_size=7, + mime_type='text/plain', + uploaded_by=user, + ) + outsider = Employee.objects.create_user( + email='attachment-outsider@example.com', + password='testpass123', + phone_number='+79997777773', + first_name='Чужой', + last_name='Получатель', + is_active=True, + email_verified=True, + send_activation_email=False, + ) + api_client.force_authenticate(user=outsider) + + response = api_client.get( + reverse( + 'api:v1:procurement:procurementitem-attachment-detail', + kwargs={ + 'pk': procurement_item.id, + 'attachment_id': attachment.id, + }, + ) + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + # ============================================================================== # ТЕСТЫ ОБНОВЛЕНИЯ ЗАЯВОК # ============================================================================== @@ -1039,6 +1187,67 @@ class TestProcurementRequestUpdate: assert response.status_code == status.HTTP_403_FORBIDDEN +class TestProcurementRequestActivity: + def test_activity_endpoint_returns_creation_and_update_history( + self, + api_client, + user, + procurement_request, + ): + api_client.force_authenticate(user=user) + detail_url = reverse( + "api:v1:procurement:procurementrequest-detail", + kwargs={"pk": procurement_request.pk}, + ) + activity_url = reverse( + "api:v1:procurement:procurementrequest-activity", + kwargs={"pk": procurement_request.pk}, + ) + + update_response = api_client.patch( + detail_url, + {"title": "Новое название истории"}, + format="json", + ) + assert update_response.status_code == status.HTTP_200_OK + + response = api_client.get(activity_url) + assert response.status_code == status.HTTP_200_OK + assert [entry["action"] for entry in response.data[:2]] == [ + ProcurementActivityAction.UPDATED, + ProcurementActivityAction.CREATED, + ] + assert response.data[0]["actor"]["id"] == user.id + assert response.data[0]["metadata"]["fields"] == ["title"] + + detail_response = api_client.get(detail_url) + assert detail_response.status_code == status.HTTP_200_OK + assert detail_response.data["activity_count"] == 2 + + def test_outsider_cannot_read_activity( + self, + api_client, + procurement_request, + ): + outsider = Employee.objects.create_user( + email="activity-outsider@example.com", + password="testpass123", + phone_number="+79998888777", + is_active=True, + email_verified=True, + send_activation_email=False, + ) + api_client.force_authenticate(user=outsider) + url = reverse( + "api:v1:procurement:procurementrequest-activity", + kwargs={"pk": procurement_request.pk}, + ) + + response = api_client.get(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + # ============================================================================== # ТЕСТЫ АДРЕСНЫХ ЗАЯВОК В ОТДЕЛ-ИСПОЛНИТЕЛЬ # ============================================================================== @@ -1542,6 +1751,11 @@ class TestProcessingDepartmentWorkflow: processing_request.refresh_from_db() assert processing_request.executor == supply_user assert processing_request.status == ProcurementStatus.IN_PROGRESS + started_activity = ProcurementRequestActivity.objects.filter( + request=processing_request, + action=ProcurementActivityAction.STARTED, + ).latest("created_at") + assert started_activity.actor == supply_user api_client.force_authenticate(user=user) edit_response = api_client.patch( @@ -1598,11 +1812,17 @@ class TestProcessingDepartmentWorkflow: processing_request.refresh_from_db() assert processing_request.executor == supply_replacement_user assert processing_request.status == ProcurementStatus.IN_PROGRESS - assert processing_request.started_at > previous_started_at + assert processing_request.started_at == previous_started_at assert response.data["executor"] == supply_replacement_user.id assert response.data["executor_name"] == ( supply_replacement_user.get_full_name() ) + reassigned_activity = ProcurementRequestActivity.objects.get( + request=processing_request, + action=ProcurementActivityAction.EXECUTOR_REASSIGNED, + ) + assert reassigned_activity.actor == supply_replacement_user + assert reassigned_activity.metadata["previous_executor_id"] == supply_user.id def test_available_scope_includes_reassignable_processing_request( self, api_client, supply_user, supply_replacement_user, @@ -1865,6 +2085,110 @@ class TestProcessingDepartmentWorkflow: item.refresh_from_db() assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED assert item.received_quantity == item.quantity + actions = set( + processing_request.activities.values_list("action", flat=True) + ) + assert ProcurementActivityAction.ITEM_UPDATED in actions + assert ProcurementActivityAction.ALL_ITEMS_RECEIVED in actions + + def test_filling_execution_automatically_starts_request( + self, api_client, supply_user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + quantity=3, + ) + api_client.force_authenticate(user=supply_user) + item_url = reverse( + 'api:v1:procurement:procurementitem-detail', + kwargs={'pk': item.id}, + ) + + response = api_client.patch( + item_url, + { + 'actual_unit_price': '125.00', + 'ordered_quantity': 1, + }, + format='json', + ) + + assert response.status_code == status.HTTP_200_OK + processing_request.refresh_from_db() + assert processing_request.status == ProcurementStatus.IN_PROGRESS + assert processing_request.executor == supply_user + assert processing_request.started_at is not None + item_activity = processing_request.activities.filter( + action=ProcurementActivityAction.ITEM_UPDATED, + object_id=item.id, + ).latest("created_at") + assert item_activity.actor == supply_user + assert set(item_activity.metadata["fields"]) == { + "actual_unit_price", + "execution_status", + "ordered_quantity", + } + + def test_later_execution_updates_keep_original_start_date( + self, api_client, supply_user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + quantity=3, + ) + original_started_at = timezone.now() - timedelta(days=2) + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.executor = supply_user + processing_request.started_at = original_started_at + processing_request.save( + update_fields=[ + 'status', + 'executor', + 'started_at', + 'updated_at', + ] + ) + api_client.force_authenticate(user=supply_user) + + response = api_client.patch( + reverse( + 'api:v1:procurement:procurementitem-detail', + kwargs={'pk': item.id}, + ), + {'ordered_quantity': 2}, + format='json', + ) + + assert response.status_code == status.HTTP_200_OK + processing_request.refresh_from_db() + assert processing_request.started_at == original_started_at + + def test_regular_item_edit_does_not_start_request( + self, api_client, user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + name='Исходное название', + ) + api_client.force_authenticate(user=user) + + response = api_client.patch( + reverse( + 'api:v1:procurement:procurementitem-detail', + kwargs={'pk': item.id}, + ), + {'name': 'Уточнённое название'}, + format='json', + ) + + assert response.status_code == status.HTTP_200_OK + processing_request.refresh_from_db() + assert processing_request.status == ProcurementStatus.WAITING + assert processing_request.executor is None + assert processing_request.started_at is None def test_author_can_edit_waiting_request_with_items_before_work( self, api_client, user, department, supply_department, diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 94db8d30..73782908 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -4,6 +4,7 @@ from datetime import date, timedelta import pytest from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType +from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from django.utils import timezone from rest_framework import status @@ -19,7 +20,15 @@ from procurement.constants import ProcurementStatus, UrgencyLevel from procurement.models import ProcurementRequest from requests_app.models import Request as EmployeeRequest from schedule.models import Calendar, CalendarRelation, Event -from tasks.models import Task, TaskBoard, TaskColumn, TaskUserSettings +from tasks.models import ( + Task, + TaskAttachment, + TaskBoard, + TaskChecklistItem, + TaskColumn, + TaskExternalLink, + TaskUserSettings, +) from tasks.realtime import get_task_board_recipient_ids pytestmark = pytest.mark.django_db @@ -180,6 +189,182 @@ def test_create_task_and_move_to_done_column(api_client, user): assert task.completed_at is not None +def test_member_can_claim_unassigned_task(api_client, user): + claimant = make_user("task-claimant@example.com", "+79994440095") + other_member = make_user("task-claim-other@example.com", "+79994440096") + board = TaskBoard.objects.create(name="Доска самоназначения", created_by=user) + board.members.add(claimant, other_member) + source_column = TaskColumn.objects.create( + board=board, + name="Новые", + position=1000, + ) + done_column = TaskColumn.objects.create( + board=board, + name="Готово", + position=2000, + is_done=True, + ) + task = Task.objects.create( + board=board, + column=source_column, + title="Свободная задача", + created_by=user, + ) + claim_url = reverse("api:v1:tasks:task-claim", kwargs={"pk": task.id}) + + api_client.force_authenticate(user=claimant) + with ( + patch("api.v1.tasks.views.notify_task_updated") as notify_updated, + patch("api.v1.tasks.views.send_task_board_update") as send_update, + ): + claim_response = api_client.post(claim_url) + + assert claim_response.status_code == status.HTTP_200_OK + assert claim_response.data["assignee"]["id"] == claimant.id + assert claim_response.data["column"] == source_column.id + task.refresh_from_db() + assert task.assignee_id == claimant.id + assert task.column_id == source_column.id + claimed_activity = task.activities.get(action="claimed") + assert claimed_activity.actor_id == claimant.id + assert claimed_activity.metadata == {"assignee_id": claimant.id} + notify_updated.assert_called_once() + send_update.assert_called_once() + send_args, send_kwargs = send_update.call_args + assert send_args[1:4] == ("claimed", "task", task.id) + assert send_kwargs["extra"] == {"assignee_id": claimant.id} + + with ( + patch("api.v1.tasks.views.notify_task_updated") as notify_updated, + patch("api.v1.tasks.views.send_task_board_update") as send_update, + ): + repeated_response = api_client.post(claim_url) + + assert repeated_response.status_code == status.HTTP_200_OK + assert task.activities.filter(action="claimed").count() == 1 + notify_updated.assert_not_called() + send_update.assert_not_called() + + api_client.force_authenticate(user=other_member) + conflict_response = api_client.post(claim_url) + assert conflict_response.status_code == status.HTTP_409_CONFLICT + assert conflict_response.data["detail"] == "У задачи уже есть исполнитель." + task.refresh_from_db() + assert task.assignee_id == claimant.id + + completed_task = Task.objects.create( + board=board, + column=done_column, + title="Завершённая задача", + created_by=user, + ) + completed_response = api_client.post( + 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"] == "Нельзя взять в работу завершённую задачу." + completed_task.refresh_from_db() + assert completed_task.assignee_id is None + + +def test_only_assignee_can_complete_task_in_first_done_column(api_client, user): + assignee = make_user("task-assignee@example.com", "+79994440093") + member = make_user("task-member@example.com", "+79994440094") + board = TaskBoard.objects.create(name="Доска завершения", created_by=user) + board.members.add(assignee, member) + source_column = TaskColumn.objects.create( + board=board, + name="В работе", + position=1000, + ) + first_done_column = TaskColumn.objects.create( + board=board, + name="Готово", + position=2000, + is_done=True, + ) + TaskColumn.objects.create( + board=board, + name="Архив завершенных", + position=3000, + is_done=True, + ) + task = Task.objects.create( + board=board, + column=source_column, + title="Завершить исполнителем", + created_by=user, + assignee=assignee, + ) + complete_url = reverse("api:v1:tasks:task-complete", kwargs={"pk": task.id}) + + api_client.force_authenticate(user=member) + forbidden_response = api_client.post(complete_url) + assert forbidden_response.status_code == status.HTTP_403_FORBIDDEN + task.refresh_from_db() + assert task.column_id == source_column.id + assert task.completed_at is None + + api_client.force_authenticate(user=assignee) + with ( + patch("api.v1.tasks.views.notify_task_moved") as notify_moved, + patch("api.v1.tasks.views.send_task_board_update") as send_update, + ): + complete_response = api_client.post(complete_url) + + assert complete_response.status_code == status.HTTP_200_OK + assert complete_response.data["column"] == first_done_column.id + assert complete_response.data["completed_at"] is not None + task.refresh_from_db() + assert task.column_id == first_done_column.id + assert task.completed_at is not None + moved_activity = task.activities.get(action="moved") + assert moved_activity.actor_id == assignee.id + assert moved_activity.metadata == { + "from_column_id": source_column.id, + "from_column": source_column.name, + "to_column_id": first_done_column.id, + "to_column": first_done_column.name, + } + notify_moved.assert_called_once() + send_update.assert_called_once() + + with ( + patch("api.v1.tasks.views.notify_task_moved") as notify_moved, + patch("api.v1.tasks.views.send_task_board_update") as send_update, + ): + repeated_response = api_client.post(complete_url) + + assert repeated_response.status_code == status.HTTP_200_OK + assert task.activities.filter(action="moved").count() == 1 + notify_moved.assert_not_called() + send_update.assert_not_called() + + +def test_assignee_cannot_complete_task_without_done_column(api_client, user): + board = TaskBoard.objects.create(name="Доска без финала", created_by=user) + column = TaskColumn.objects.create(board=board, name="В работе", position=1000) + task = Task.objects.create( + board=board, + column=column, + title="Некуда завершать", + created_by=user, + assignee=user, + ) + api_client.force_authenticate(user=user) + + response = api_client.post( + reverse("api:v1:tasks:task-complete", kwargs={"pk": task.id}) + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["detail"] == "На доске не настроена завершающая колонка." + task.refresh_from_db() + assert task.column_id == column.id + assert task.completed_at is None + + def test_task_rejects_column_from_another_board(api_client, user): api_client.force_authenticate(user=user) first = api_client.get(reverse("api:v1:tasks:task-board-default")).data @@ -207,6 +392,308 @@ def test_task_rejects_column_from_another_board(api_client, user): assert "column" in response.data +def test_task_attachments_follow_board_access_and_support_full_lifecycle( + api_client, + user, + tmp_path, + settings, + django_capture_on_commit_callbacks, +): + settings.MEDIA_ROOT = tmp_path + member = make_user("task-file-member@example.com", "+79994440101") + outsider = make_user("task-file-outsider@example.com", "+79994440102") + board = TaskBoard.objects.create(name="Доска с файлами", created_by=user) + board.members.add(member) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + task = Task.objects.create( + board=board, + column=column, + title="Задача с файлами", + created_by=user, + ) + attachments_url = reverse( + "api:v1:tasks:task-attachments", + kwargs={"pk": task.id}, + ) + + api_client.force_authenticate(user=member) + upload_response = api_client.post( + attachments_url, + { + "files": [ + SimpleUploadedFile( + "brief.txt", + b"task brief", + content_type="text/plain", + ), + SimpleUploadedFile( + "report.pdf", + b"pdf content", + content_type="application/pdf", + ), + SimpleUploadedFile( + "logs.tar.gz", + b"archive content", + content_type="application/gzip", + ), + ] + }, + format="multipart", + ) + + assert upload_response.status_code == status.HTTP_201_CREATED + assert [item["file_name"] for item in upload_response.data] == [ + "brief.txt", + "report.pdf", + "logs.tar.gz", + ] + assert all(item["uploaded_by"]["id"] == member.id for item in upload_response.data) + assert TaskAttachment.objects.filter(task=task).count() == 3 + + task_response = api_client.get( + reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}) + ) + assert task_response.status_code == status.HTTP_200_OK + assert task_response.data["attachments_count"] == 3 + + attachment = TaskAttachment.objects.get(task=task, file_name="brief.txt") + stored_name = attachment.file.name + download_url = reverse( + "api:v1:tasks:task-attachment-download", + kwargs={"pk": task.id, "attachment_id": attachment.id}, + ) + download_response = api_client.get(download_url) + assert download_response.status_code == status.HTTP_200_OK + assert b"".join(download_response.streaming_content) == b"task brief" + assert download_response["X-Content-Type-Options"] == "nosniff" + assert "attachment" in download_response["Content-Disposition"] + + api_client.force_authenticate(user=outsider) + assert api_client.get(attachments_url).status_code == status.HTTP_404_NOT_FOUND + assert api_client.get(download_url).status_code == status.HTTP_404_NOT_FOUND + + api_client.force_authenticate(user=member) + delete_url = reverse( + "api:v1:tasks:task-attachment-detail", + kwargs={"pk": task.id, "attachment_id": attachment.id}, + ) + with django_capture_on_commit_callbacks(execute=True): + delete_response = api_client.delete(delete_url) + + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + assert not TaskAttachment.objects.filter(id=attachment.id).exists() + assert not attachment.file.storage.exists(stored_name) + + +def test_task_attachment_has_no_size_limit( + api_client, + user, + settings, +): + settings.TASK_ATTACHMENT_MAX_SIZE = 4 + board = TaskBoard.objects.create(name="Доска лимитов", created_by=user) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + task = Task.objects.create( + board=board, + column=column, + title="Проверить лимит файла", + created_by=user, + ) + api_client.force_authenticate(user=user) + + response = api_client.post( + reverse("api:v1:tasks:task-attachments", kwargs={"pk": task.id}), + { + "files": SimpleUploadedFile( + "large.txt", + b"12345", + content_type="text/plain", + ) + }, + format="multipart", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert TaskAttachment.objects.filter(task=task, file_name="large.txt").exists() + + +def test_task_external_links_follow_board_access_and_support_lifecycle( + api_client, + user, +): + member = make_user("task-link-member@example.com", "+79994440103") + outsider = make_user("task-link-outsider@example.com", "+79994440104") + board = TaskBoard.objects.create(name="Доска со ссылками", created_by=user) + board.members.add(member) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + task = Task.objects.create( + board=board, + column=column, + title="Задача со ссылкой", + created_by=user, + ) + links_url = reverse( + "api:v1:tasks:task-external-links", + kwargs={"pk": task.id}, + ) + + api_client.force_authenticate(user=member) + invalid_response = api_client.post( + links_url, + {"url": "javascript:alert(1)", "title": "Опасная ссылка"}, + format="json", + ) + assert invalid_response.status_code == status.HTTP_400_BAD_REQUEST + + create_response = api_client.post( + links_url, + {"url": "https://example.com/service/item/42", "title": "Карточка сервиса"}, + format="json", + ) + assert create_response.status_code == status.HTTP_201_CREATED + assert create_response.data["title"] == "Карточка сервиса" + assert create_response.data["created_by"]["id"] == member.id + link_id = create_response.data["id"] + assert TaskExternalLink.objects.filter(task=task).count() == 1 + + duplicate_response = api_client.post( + links_url, + {"url": "https://example.com/service/item/42", "title": "Обновленное название"}, + format="json", + ) + assert duplicate_response.status_code == status.HTTP_200_OK + assert duplicate_response.data["title"] == "Обновленное название" + assert TaskExternalLink.objects.filter(task=task).count() == 1 + + task_response = api_client.get( + reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}) + ) + assert task_response.status_code == status.HTTP_200_OK + assert task_response.data["linked_objects_count"] == 1 + + api_client.force_authenticate(user=outsider) + assert api_client.get(links_url).status_code == status.HTTP_404_NOT_FOUND + + api_client.force_authenticate(user=member) + delete_response = api_client.delete( + reverse( + "api:v1:tasks:task-external-link-detail", + kwargs={"pk": task.id, "link_id": link_id}, + ) + ) + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + assert not TaskExternalLink.objects.filter(id=link_id).exists() + + +def test_task_checklist_follows_board_access_and_supports_full_lifecycle( + api_client, +): + owner = make_user("task-checklist-owner@example.com", "+79994440105") + member = make_user("task-checklist-member@example.com", "+79994440106") + outsider = make_user("task-checklist-outsider@example.com", "+79994440107") + board = TaskBoard.objects.create(name="Доска с чек-листом", created_by=owner) + board.members.add(member) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + task = Task.objects.create( + board=board, + column=column, + title="Подготовить релиз", + created_by=owner, + ) + checklist_url = reverse( + "api:v1:tasks:task-checklist", + kwargs={"pk": task.id}, + ) + + api_client.force_authenticate(user=member) + invalid_response = api_client.post( + checklist_url, + {"title": " "}, + format="json", + ) + assert invalid_response.status_code == status.HTTP_400_BAD_REQUEST + + first_response = api_client.post( + checklist_url, + {"title": " Проверить миграции "}, + format="json", + ) + second_response = api_client.post( + checklist_url, + {"title": "Обновить документацию"}, + format="json", + ) + assert first_response.status_code == status.HTTP_201_CREATED + assert second_response.status_code == status.HTTP_201_CREATED + assert first_response.data["title"] == "Проверить миграции" + assert first_response.data["position"] == 1000 + assert second_response.data["position"] == 2000 + assert first_response.data["created_by"]["id"] == member.id + + item_id = first_response.data["id"] + item_url = reverse( + "api:v1:tasks:task-checklist-item-detail", + kwargs={"pk": task.id, "item_id": item_id}, + ) + complete_response = api_client.patch( + item_url, + {"is_completed": True}, + format="json", + ) + assert complete_response.status_code == status.HTTP_200_OK + assert complete_response.data["is_completed"] is True + assert complete_response.data["completed_by"]["id"] == member.id + assert complete_response.data["completed_at"] is not None + + completed_at = complete_response.data["completed_at"] + rename_response = api_client.patch( + item_url, + {"title": "Проверить миграции на стенде"}, + format="json", + ) + assert rename_response.status_code == status.HTTP_200_OK + assert rename_response.data["title"] == "Проверить миграции на стенде" + assert rename_response.data["completed_by"]["id"] == member.id + assert rename_response.data["completed_at"] == completed_at + + list_response = api_client.get(checklist_url) + assert list_response.status_code == status.HTTP_200_OK + assert [item["title"] for item in list_response.data] == [ + "Проверить миграции на стенде", + "Обновить документацию", + ] + + detail_response = api_client.get( + reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}) + ) + assert detail_response.status_code == status.HTTP_200_OK + assert detail_response.data["checklist_total"] == 2 + assert detail_response.data["checklist_completed"] == 1 + + api_client.force_authenticate(user=outsider) + assert api_client.get(checklist_url).status_code == status.HTTP_404_NOT_FOUND + assert ( + api_client.patch(item_url, {"is_completed": False}, format="json").status_code + == status.HTTP_404_NOT_FOUND + ) + + api_client.force_authenticate(user=member) + delete_response = api_client.delete(item_url) + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + assert not TaskChecklistItem.objects.filter(id=item_id).exists() + + activity_response = api_client.get( + reverse("api:v1:tasks:task-activity", kwargs={"pk": task.id}) + ) + assert activity_response.status_code == status.HTTP_200_OK + assert { + "checklist_item_added", + "checklist_item_completed", + "checklist_item_updated", + "checklist_item_removed", + }.issubset({item["action"] for item in activity_response.data}) + + def test_create_board_accepts_members_and_departments(api_client, user): member = make_user("task-member@example.com", "+79994440002") department = Department.objects.create(name="Проектный отдел") @@ -224,6 +711,7 @@ def test_create_board_accepts_members_and_departments(api_client, user): ) assert response.status_code == status.HTTP_201_CREATED + assert response.data["access_scope"] == "restricted" assert response.data["members"] == [member.id] assert response.data["member_details"][0]["id"] == member.id assert response.data["departments"] == [department.id] @@ -231,6 +719,61 @@ def test_create_board_accepts_members_and_departments(api_client, user): assert TaskColumn.objects.filter(board_id=response.data["id"]).count() == 4 +def test_private_board_is_available_only_to_owner_and_admin(api_client): + owner = make_user("task-private-owner@example.com", "+79994440901") + selected_member = make_user("task-private-member@example.com", "+79994440902") + department_member = make_user("task-private-dept@example.com", "+79994440903") + outsider = make_user("task-private-outsider@example.com", "+79994440904") + admin = make_user( + "task-private-admin@example.com", + "+79994440905", + is_staff=True, + ) + department = Department.objects.create(name="Приватный отдел") + EmployeeDepartment.objects.create( + employee=department_member, + department=department, + is_active=True, + ) + api_client.force_authenticate(user=owner) + + create_response = api_client.post( + reverse("api:v1:tasks:task-board-list"), + { + "name": "Личная доска", + "access_scope": "private", + "members": [selected_member.id], + "departments": [department.id], + }, + format="json", + ) + + assert create_response.status_code == status.HTTP_201_CREATED + assert create_response.data["access_scope"] == "private" + assert create_response.data["members"] == [] + assert create_response.data["departments"] == [] + board = TaskBoard.objects.get(id=create_response.data["id"]) + assert get_task_board_recipient_ids(board) == {owner.id, admin.id} + + for hidden_user in (selected_member, department_member, outsider): + 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"] + } + detail_response = api_client.get( + reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}) + ) + assert detail_response.status_code == status.HTTP_404_NOT_FOUND + + api_client.force_authenticate(user=admin) + admin_response = api_client.get( + reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}) + ) + assert admin_response.status_code == status.HTTP_200_OK + + def test_board_member_can_access_private_board(api_client): owner = make_user("task-owner@example.com", "+79994440003") member = make_user("task-member-2@example.com", "+79994440004") @@ -1256,14 +1799,65 @@ def test_task_comments_use_communications_and_update_count(api_client, user): message = Message.objects.get(chat=chat) assert message.content == "Нужно уточнить детали" assert message.author == user + added_activity = task.activities.get(action="comment_added") + assert added_activity.actor == user + assert added_activity.object_kind == "comment" + assert added_activity.object_id == message.id + assert added_activity.metadata == {"comment_text": "Нужно уточнить детали"} + + comment_detail_url = reverse( + "api:v1:tasks:task-delete-comment", + kwargs={"pk": task.id, "comment_id": message.id}, + ) + other_user = make_user("task-comment-editor@example.com", "+79994440027") + api_client.force_authenticate(user=other_user) + forbidden_edit_response = api_client.patch( + comment_detail_url, + {"text": "Чужое изменение"}, + format="json", + ) + assert forbidden_edit_response.status_code == status.HTTP_403_FORBIDDEN + + api_client.force_authenticate(user=user) + empty_edit_response = api_client.patch( + comment_detail_url, + {"text": ""}, + format="json", + ) + assert empty_edit_response.status_code == status.HTTP_400_BAD_REQUEST + + edit_response = api_client.patch( + comment_detail_url, + {"text": "Детали уже уточнены"}, + format="json", + ) + assert edit_response.status_code == status.HTTP_200_OK + assert edit_response.data["text"] == "Детали уже уточнены" + assert edit_response.data["is_edited"] is True + assert edit_response.data["edited_at"] is not None + + message.refresh_from_db() + assert message.content == "Детали уже уточнены" + edit_history = message.edit_history_records.get() + assert edit_history.previous_content == "Нужно уточнить детали" + assert edit_history.edited_by == user + edited_activity = task.activities.get(action="comment_edited") + assert edited_activity.actor == user + assert edited_activity.object_kind == "comment" + assert edited_activity.object_id == message.id + assert edited_activity.metadata == { + "previous_text": "Нужно уточнить детали", + "comment_text": "Детали уже уточнены", + } list_response = api_client.get( reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}) ) assert list_response.status_code == status.HTTP_200_OK assert [comment["text"] for comment in list_response.data] == [ - "Нужно уточнить детали" + "Детали уже уточнены" ] + assert list_response.data[0]["is_edited"] is True detail_response = api_client.get( reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}) @@ -1271,14 +1865,26 @@ def test_task_comments_use_communications_and_update_count(api_client, user): assert detail_response.data["comments_count"] == 1 delete_response = api_client.delete( - reverse( - "api:v1:tasks:task-delete-comment", - kwargs={"pk": task.id, "comment_id": message.id}, - ) + comment_detail_url ) assert delete_response.status_code == status.HTTP_204_NO_CONTENT message.refresh_from_db() assert message.is_deleted is True + removed_activity = task.activities.get(action="comment_removed") + assert removed_activity.actor == user + assert removed_activity.object_kind == "comment" + assert removed_activity.object_id == message.id + assert removed_activity.metadata == {"comment_text": "Детали уже уточнены"} + + activity_response = api_client.get( + reverse("api:v1:tasks:task-activity", kwargs={"pk": task.id}) + ) + assert activity_response.status_code == status.HTTP_200_OK + assert [item["action"] for item in activity_response.data] == [ + "comment_removed", + "comment_edited", + "comment_added", + ] assert api_client.get( reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}) diff --git a/backend/tests/employees/test_ldap_avatar_processing.py b/backend/tests/employees/test_ldap_avatar_processing.py new file mode 100644 index 00000000..47879c1a --- /dev/null +++ b/backend/tests/employees/test_ldap_avatar_processing.py @@ -0,0 +1,34 @@ +import io +import random + +from PIL import Image + +from employees.ldap.utils.image_utils import normalize_avatar_to_jpeg + + +def _noisy_png(size: int = 768) -> bytes: + pixels = random.Random(42).randbytes(size * size * 3) + image = Image.frombytes("RGB", (size, size), pixels) + output = io.BytesIO() + image.save(output, format="PNG") + return output.getvalue() + + +def test_normalize_avatar_enforces_ldap_byte_limit(): + result = normalize_avatar_to_jpeg(_noisy_png(), size_px=384, max_kb=100) + + assert len(result) <= 100 * 1024 + + image = Image.open(io.BytesIO(result)) + assert image.format == "JPEG" + assert image.width <= 384 + assert image.height <= 384 + + +def test_normalize_avatar_rejects_invalid_output_limit(): + try: + normalize_avatar_to_jpeg(b"image", max_kb=0) + except ValueError as exc: + assert "лимит размера" in str(exc) + else: + raise AssertionError("Ожидалась ошибка для нулевого лимита") diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx index 83ed1346..47bd210e 100644 --- a/frontend/src/app/documents/page.tsx +++ b/frontend/src/app/documents/page.tsx @@ -40,6 +40,7 @@ import { BulkActionsToolbar, useDocumentSelection } from "@/components/documents import { TagManagementModal } from "@/components/documents/tags"; import TaskLinkPill from "@/components/tasks/TaskLinkPill"; import { Modal } from "@/components/ui"; +import { canPreviewDocument, getDocumentFileExtension } from "@/lib/document-preview"; // Динамический импорт компонентов с PDF обработкой (избегаем SSR ошибок с DOMMatrix) const DocumentUploadForm = dynamic( @@ -68,99 +69,6 @@ function formatDate(value?: string): string { }); } -const DOCUMENT_PREVIEW_EXTENSIONS = new Set([ - "pdf", - "jpg", - "jpeg", - "png", - "gif", - "webp", - "svg", - "bmp", - "ico", - "avif", - "docx", - "xls", - "xlsx", - "xlsm", - "xlsb", - "ods", - "csv", - "tsv", - "txt", - "text", - "md", - "markdown", - "json", - "xml", - "html", - "htm", - "css", - "scss", - "sass", - "less", - "js", - "jsx", - "ts", - "tsx", - "mjs", - "cjs", - "yml", - "yaml", - "ini", - "conf", - "cfg", - "log", - "sql", - "py", - "java", - "c", - "cpp", - "h", - "hpp", - "cs", - "go", - "rs", - "php", - "rb", - "kt", - "kts", - "swift", - "sh", - "bash", - "zsh", - "ps1", - "bat", - "cmd", - "properties", - "env", - "rtf", - "mp4", - "webm", - "ogg", - "ogv", - "mov", - "m4v", - "mp3", - "wav", - "oga", - "m4a", - "aac", - "flac", -]); -const DOCUMENT_PREVIEW_FILE_NAMES = new Set(["dockerfile", "makefile", "readme", "license", ".gitignore", ".env"]); - -function getDocumentFileExtension(fileName?: string | null): string { - const cleanName = (fileName || "").toLowerCase().split("?")[0].split("#")[0]; - return cleanName.match(/\.([^.]+)$/)?.[1] || ""; -} - -function canPreviewDocument(fileName?: string | null): boolean { - const lowerName = (fileName || "").toLowerCase(); - const extension = getDocumentFileExtension(fileName); - return DOCUMENT_PREVIEW_EXTENSIONS.has(extension) || DOCUMENT_PREVIEW_FILE_NAMES.has(lowerName); -} - function formatFileSize(value?: number): string { if (!value || value <= 0) return ""; if (value < 1024) return `${value} Б`; @@ -1322,6 +1230,8 @@ function DocumentsPageContent() { const departmentsCount = doc.departments?.length || 0; const hasPreview = Boolean(doc.file_url && canPreviewDocument(doc.file_name || doc.title)); const isAcknowledging = acknowledgingDocumentId === doc.id; + const acknowledgementRequiredForUser = + doc.acknowledgement_required_for_user ?? doc.acknowledgement_required; // DEBUG: Проверка данных документа if (!authorName || !createdDate) { @@ -1434,7 +1344,7 @@ function DocumentsPageContent() {
- {doc.acknowledgement_required ? ( + {acknowledgementRequiredForUser ? ( doc.is_acknowledged ? ( Ознакомлен @@ -1538,7 +1448,7 @@ function DocumentsPageContent() { )} - {doc.acknowledgement_required && !doc.is_acknowledged && ( + {acknowledgementRequiredForUser && !doc.is_acknowledged && ( )} @@ -1641,7 +1551,7 @@ function DocumentsPageContent() { )}
- {doc.acknowledgement_required && !doc.is_acknowledged ? ( + {acknowledgementRequiredForUser && !doc.is_acknowledged ? ( ) : null} @@ -1732,7 +1642,7 @@ function DocumentsPageContent() { isOpen={showUploadForm} onClose={() => setShowUploadForm(false)} title="Загрузить документ" - size="lg" + size="xl" > 5 * 1024 * 1024) { - alert("Файл слишком большой. Максимальный размер: 5MB"); - return; - } - // Проверка типа if (!file.type.startsWith("image/")) { alert("Можно загружать только изображения"); diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx index ce25906a..d41a3124 100644 --- a/frontend/src/app/messages/page.tsx +++ b/frontend/src/app/messages/page.tsx @@ -389,10 +389,6 @@ export default function MessagesPage() { const handleAvatarChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { - if (file.size > 5 * 1024 * 1024) { // 5MB - alert('Файл слишком большой. Максимум 5MB'); - return; - } if (!file.type.startsWith('image/')) { alert('Можно загружать только изображения'); return; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 033ff840..20ce6a4f 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,9 +1,15 @@ "use client"; import Link from "next/link"; -import { ChevronRight, Link2, Pencil, Pin, Plus, Trash2 } from "lucide-react"; +import dynamic from "next/dynamic"; +import { ChevronRight, Link2, Newspaper, Pencil, Pin, Plus, ScrollText, Trash2 } from "lucide-react"; import { AppShell } from "../components/AppShell"; import { FeedPostCard } from "@/components/feed/FeedPostCard"; +import { FeedRegulationCard } from "@/components/feed/FeedRegulationCard"; +import { DocumentAcknowledgementsReport } from "@/components/documents/DocumentAcknowledgementsReport"; +import { DocumentDetailModal } from "@/components/documents/DocumentDetailModal"; +import { DocumentMetadataEditor } from "@/components/documents/DocumentMetadataEditor"; +import { DocumentTaskLinks } from "@/components/documents/DocumentTaskLinks"; import { Modal } from "@/components/ui"; import { PostCommentsModal } from "@/components/feed/PostCommentsModal"; import { RequestAvatar } from "@/components/requests/RequestAvatar"; @@ -11,12 +17,29 @@ import { RelatedTaskLinks } from "@/components/tasks/RelatedTaskLinks"; import TaskLinkPill from "@/components/tasks/TaskLinkPill"; import { useNotifications } from "@/contexts/NotificationsContext"; import { apiClient } from "@/lib/api"; -import { userProfileLink } from "@/lib/shared"; +import { getDocumentFileExtension } from "@/lib/document-preview"; +import { loadAllPages, userProfileLink } from "@/lib/shared"; import { resolveMediaUrl } from "@/lib/url"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import type { Comment, Post } from "@/types/api"; +import type { Comment, Document, Post } from "@/types/api"; import { useUser } from "@/contexts/UserContext"; +import { toast } from "sonner"; + +const EnhancedPDFViewer = dynamic( + () => import("@/components/documents/viewer").then((mod) => ({ default: mod.EnhancedPDFViewer })), + { ssr: false }, +); + +const DocumentPreview = dynamic( + () => import("@/components/documents/DocumentPreview").then((mod) => ({ default: mod.DocumentPreview })), + { ssr: false }, +); + +const DocumentUploadForm = dynamic( + () => import("@/components/documents/DocumentUploadForm").then((mod) => ({ default: mod.DocumentUploadForm })), + { ssr: false }, +); type LikeUser = { id: number; @@ -27,6 +50,24 @@ type LikeUser = { }; type PostSourceFilter = "all" | "company" | `department:${number}`; +type FeedFilterKey = PostSourceFilter | "regulations"; + +type FeedEntry = + | { kind: "post"; post: Post; timestamp: string } + | { kind: "regulation"; document: Document; timestamp: string }; + +function postMatchesSource(post: Post, source: PostSourceFilter) { + if (source === "all") return true; + if (source === "company") return post.type !== "department"; + return post.department_id === Number(source.split(":")[1]); +} + +function regulationMatchesSource(document: Document, source: PostSourceFilter) { + if (source === "all") return true; + if (source === "company") return Boolean(document.sent_to_all); + const departmentId = Number(source.split(":")[1]); + return (document.departments || []).some((department) => department.id === departmentId); +} export default function Home() { return ( @@ -54,14 +95,29 @@ function HomePageContent() { const pathname = usePathname(); const searchParams = useSearchParams(); const [posts, setPosts] = useState([]); + const [regulations, setRegulations] = useState([]); + const [selectedRegulation, setSelectedRegulation] = useState(null); + const [selectedRegulationInitialTab, setSelectedRegulationInitialTab] = useState<"comments" | undefined>(); + const [metadataRegulation, setMetadataRegulation] = useState(null); + const [taskLinkRegulation, setTaskLinkRegulation] = useState(null); + const [regulationPreviewFile, setRegulationPreviewFile] = useState<{ url: string; name: string } | null>(null); + const [regulationPdfFile, setRegulationPdfFile] = useState<{ url: string; name: string } | null>(null); + const [acknowledgingRegulationId, setAcknowledgingRegulationId] = useState(null); + const [acknowledgementsReport, setAcknowledgementsReport] = useState<{ + documentId: number; + documentTitle: string; + } | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [likeBusyId, setLikeBusyId] = useState(null); const [sourceFilter, setSourceFilter] = useState("all"); + const [regulationsOnly, setRegulationsOnly] = useState(false); const [commentsOpen, setCommentsOpen] = useState(false); const [activePost, setActivePost] = useState(null); const [createPostOpen, setCreatePostOpen] = useState(false); + const [createRegulationOpen, setCreateRegulationOpen] = useState(false); + const [createMenuOpen, setCreateMenuOpen] = useState(false); const [createType, setCreateType] = useState<"company" | "department">("company"); const [createDepartmentId, setCreateDepartmentId] = useState(""); const [createTitle, setCreateTitle] = useState(""); @@ -80,6 +136,7 @@ function HomePageContent() { const [postMenuOpenId, setPostMenuOpenId] = useState(null); const [taskLinkPost, setTaskLinkPost] = useState(null); const postMenuRef = useRef(null); + const createMenuRef = useRef(null); const formatUserName = (u: LikeUser) => { const composed = `${u.last_name || ""} ${u.first_name || ""}`.trim(); @@ -228,6 +285,80 @@ function HomePageContent() { setPosts(sortPostsPinnedFirst(response.results)); }, [sortPostsPinnedFirst]); + const refreshRegulations = useCallback(async () => { + const items = await loadAllPages((pagination) => ( + apiClient.getDocuments({ + ...pagination, + scope: "mine", + is_regulation: true, + }) + )); + setRegulations(items); + }, []); + + const handleAcknowledgeRegulation = async (documentItem: Document) => { + if (acknowledgingRegulationId !== null) return; + + setAcknowledgingRegulationId(documentItem.id); + try { + await apiClient.acknowledgeDocument(documentItem.id); + setRegulations((current) => current.map((document) => ( + document.id === documentItem.id ? { ...document, is_acknowledged: true } : document + ))); + if (selectedRegulation?.id === documentItem.id) { + setSelectedRegulation({ ...selectedRegulation, is_acknowledged: true }); + } + toast.success("Ознакомление подтверждено"); + void refreshRegulations(); + } catch (err) { + console.error("Ошибка подтверждения ознакомления:", err); + toast.error("Не удалось подтвердить ознакомление"); + } finally { + setAcknowledgingRegulationId(null); + } + }; + + const openRegulation = (document: Document) => { + setSelectedRegulationInitialTab(undefined); + setSelectedRegulation(document); + }; + + const openRegulationComments = (document: Document) => { + setSelectedRegulationInitialTab("comments"); + setSelectedRegulation(document); + }; + + const openRegulationPreview = (document: Document) => { + if (!document.file_url) return; + + const previewFile = { + url: document.file_url, + name: document.file_name || document.title, + }; + if (getDocumentFileExtension(previewFile.name) === "pdf") { + setRegulationPdfFile(previewFile); + } else { + setRegulationPreviewFile(previewFile); + } + }; + + const deleteRegulation = async (document: Document) => { + if (!window.confirm(`Удалить документ "${document.title}"?`)) return; + + try { + await apiClient.deleteDocument(document.id); + setRegulations((current) => current.filter((item) => item.id !== document.id)); + if (selectedRegulation?.id === document.id) { + setSelectedRegulation(null); + setSelectedRegulationInitialTab(undefined); + } + toast.success("Документ удалён"); + } catch (err) { + console.error("Ошибка удаления документа:", err); + toast.error("Не удалось удалить документ"); + } + }; + const postSourceCounts = useMemo(() => { const departmentCounts = new Map(); let companyCount = 0; @@ -249,30 +380,72 @@ function HomePageContent() { } } + for (const document of regulations) { + if (document.sent_to_all) companyCount += 1; + for (const department of document.departments || []) { + const current = departmentCounts.get(department.id); + if (current) { + current.total += 1; + } else { + departmentCounts.set(department.id, { + id: department.id, + name: department.name, + total: 1, + }); + } + } + } + const departments = Array.from(departmentCounts.values()).sort((left, right) => { if (right.total !== left.total) return right.total - left.total; return left.name.localeCompare(right.name, "ru"); }); return { - all: posts.length, + all: posts.length + regulations.length, company: companyCount, + regulations: regulations.length, departments, }; - }, [posts]); + }, [posts, regulations]); const postSourceUnreadCounts = useMemo(() => { const departmentUnread = new Map(); let companyUnread = 0; + let regulationsUnread = 0; + let companyRegulationsUnread = 0; + const departmentRegulationsUnread = new Map(); + const regulationsById = new Map(regulations.map((document) => [document.id, document])); for (const notification of notifications) { - if ((notification.verb || "") !== "feed_new_post") continue; - const rawData = notification.data; const data = rawData && typeof rawData === "object" ? rawData as Record : null; + if ((notification.verb || "") === "document_ready") { + const rawDocumentId = data?.document_id; + const documentId = typeof rawDocumentId === "number" + ? rawDocumentId + : typeof rawDocumentId === "string" + ? Number(rawDocumentId) + : null; + const regulation = documentId !== null ? regulationsById.get(documentId) : undefined; + if (regulation) { + regulationsUnread += 1; + if (regulation.sent_to_all) companyRegulationsUnread += 1; + for (const department of regulation.departments || []) { + departmentRegulationsUnread.set( + department.id, + (departmentRegulationsUnread.get(department.id) || 0) + 1, + ); + } + } + continue; + } + + if ((notification.verb || "") !== "feed_new_post") continue; + const postType = typeof data?.post_type === "string" ? data.post_type : ""; const departmentId = typeof data?.department_id === "number" @@ -288,25 +461,43 @@ function HomePageContent() { } } - const departmentEntries = Array.from(departmentUnread.entries()).map(([id, unread]) => ({ + const departmentIds = new Set([ + ...departmentUnread.keys(), + ...departmentRegulationsUnread.keys(), + ]); + const departmentEntries = Array.from(departmentIds).map((id) => ({ id, - unread, + unread: (departmentUnread.get(id) || 0) + (departmentRegulationsUnread.get(id) || 0), + regulationsUnread: departmentRegulationsUnread.get(id) || 0, })); return { - all: companyUnread + departmentEntries.reduce((sum, entry) => sum + entry.unread, 0), - company: companyUnread, + all: companyUnread + regulationsUnread + Array.from(departmentUnread.values()).reduce((sum, unread) => sum + unread, 0), + company: companyUnread + companyRegulationsUnread, + regulations: regulationsUnread, + companyRegulations: companyRegulationsUnread, departments: departmentEntries, }; - }, [notifications]); + }, [notifications, regulations]); const postSourceOptions = useMemo(() => { const unreadByDepartment = new Map( postSourceUnreadCounts.departments.map((entry) => [entry.id, entry.unread]), ); - const options: Array<{ key: PostSourceFilter; label: string; total: number; unread: number }> = [ + const departmentRegulationsUnread = new Map( + postSourceUnreadCounts.departments.map((entry) => [entry.id, entry.regulationsUnread]), + ); + const scopedRegulations = regulations.filter((document) => regulationMatchesSource(document, sourceFilter)); + const scopedRegulationsUnread = sourceFilter === "all" + ? postSourceUnreadCounts.regulations + : sourceFilter === "company" + ? postSourceUnreadCounts.companyRegulations + : departmentRegulationsUnread.get(Number(sourceFilter.split(":")[1])) || 0; + + const options: Array<{ key: FeedFilterKey; label: string; total: number; unread: number }> = [ { key: "all", label: "Все", total: postSourceCounts.all, unread: postSourceUnreadCounts.all }, + { key: "regulations", label: "Регламенты", total: scopedRegulations.length, unread: scopedRegulationsUnread }, { key: "company", label: "Компания", total: postSourceCounts.company, unread: postSourceUnreadCounts.company }, ]; @@ -319,24 +510,38 @@ function HomePageContent() { }); } - return options.filter(({ key, total }) => key === "all" || total > 0); - }, [postSourceCounts, postSourceUnreadCounts]); + return options.filter(({ key, total }) => key === "all" || key === "regulations" || total > 0); + }, [postSourceCounts, postSourceUnreadCounts, regulations, sourceFilter]); - const filteredPosts = useMemo(() => { - if (sourceFilter === "company") { - return posts.filter((post) => post.type !== "department"); - } - if (sourceFilter.startsWith("department:")) { - const departmentId = Number(sourceFilter.split(":")[1]); - return posts.filter((post) => post.department_id === departmentId); - } - return posts; - }, [posts, sourceFilter]); + const filteredEntries = useMemo(() => { + const postEntries: FeedEntry[] = posts.map((post) => ({ + kind: "post", + post, + timestamp: post.created_at, + })); + const regulationEntries: FeedEntry[] = regulations.map((document) => ({ + kind: "regulation", + document, + timestamp: document.uploaded_at || document.created_at, + })); + + return [...postEntries, ...regulationEntries].filter((entry) => { + if (regulationsOnly && entry.kind !== "regulation") return false; + return entry.kind === "post" + ? postMatchesSource(entry.post, sourceFilter) + : regulationMatchesSource(entry.document, sourceFilter); + }).sort((left, right) => { + const leftPinned = left.kind === "post" && left.post.pinned ? 1 : 0; + const rightPinned = right.kind === "post" && right.post.pinned ? 1 : 0; + if (leftPinned !== rightPinned) return rightPinned - leftPinned; + return new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime(); + }); + }, [posts, regulations, regulationsOnly, sourceFilter]); useEffect(() => { async function loadPosts() { try { - await refreshPosts(); + await Promise.all([refreshPosts(), refreshRegulations()]); } catch (err: unknown) { console.error('Ошибка загрузки ленты:', err); setError('Не удалось загрузить ленту'); @@ -345,7 +550,7 @@ function HomePageContent() { } } loadPosts(); - }, [refreshPosts]); + }, [refreshPosts, refreshRegulations]); useEffect(() => { if (postMenuOpenId === null) return; @@ -371,6 +576,26 @@ function HomePageContent() { }; }, [postMenuOpenId]); + useEffect(() => { + if (!createMenuOpen) return; + + const handlePointerDown = (event: MouseEvent) => { + if (createMenuRef.current && !createMenuRef.current.contains(event.target as Node)) { + setCreateMenuOpen(false); + } + }; + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setCreateMenuOpen(false); + }; + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleEscape); + }; + }, [createMenuOpen]); + useEffect(() => { if (createType !== "department") return; if (createDepartmentId) return; @@ -528,6 +753,7 @@ function HomePageContent() { }, [activePost?.id, commentsOpen, linkedPostId, posts]); const openCreatePostModal = () => { + setCreateMenuOpen(false); setCreateError(null); setEditingPostId(null); setCreateTitle(""); @@ -543,6 +769,11 @@ function HomePageContent() { setCreatePostOpen(true); }; + const openCreateRegulationModal = () => { + setCreateMenuOpen(false); + setCreateRegulationOpen(true); + }; + const openEditPostModal = (post: Post) => { setCreateError(null); setEditingPostId(post.id); @@ -651,13 +882,20 @@ function HomePageContent() {
{postSourceOptions.map(({ key, label, total, unread }) => { - const active = sourceFilter === key; + const active = key === "regulations" ? regulationsOnly : sourceFilter === key; - return ( + return (
- {posts.length === 0 ? ( + {posts.length === 0 && regulations.length === 0 ? (
-

Пока нет постов в ленте

+

Пока нет публикаций в ленте

- ) : filteredPosts.length === 0 ? ( + ) : filteredEntries.length === 0 ? (

Публикации не найдены @@ -695,7 +933,31 @@ function HomePageContent() {

) : ( - filteredPosts.map((post) => { + filteredEntries.map((entry) => { + if (entry.kind === "regulation") { + return ( + void handleAcknowledgeRegulation(document)} + onDelete={(document) => void deleteRegulation(document)} + onEdit={setMetadataRegulation} + onLinkTask={setTaskLinkRegulation} + onMove={setMetadataRegulation} + onOpen={openRegulation} + onOpenComments={openRegulationComments} + onPreview={openRegulationPreview} + onOpenAcknowledgements={(document) => setAcknowledgementsReport({ + documentId: document.id, + documentTitle: document.title, + })} + /> + ); + } + + const post = entry.post; // Форматируем дату const postDate = new Date(post.created_at); const now = new Date(); @@ -710,7 +972,7 @@ function HomePageContent() { return ( - {canCreatePost && !createPostOpen ? ( -
+ {user && !createPostOpen && !createRegulationOpen ? ( +
+ {createMenuOpen ? ( +
+ {canCreatePost ? ( + + ) : null} + +
+ ) : null}
) : null} + setCreateRegulationOpen(false)} + title="Создать регламент" + size="xl" + > + setCreateRegulationOpen(false)} + onSuccess={() => { + setCreateRegulationOpen(false); + void refreshRegulations(); + }} + /> + + setCreatePostOpen(false)} @@ -979,6 +1286,85 @@ function HomePageContent() { post={activePost} /> + { + setSelectedRegulation(null); + setSelectedRegulationInitialTab(undefined); + }} + onUpdate={() => { + void refreshRegulations(); + if (selectedRegulation) { + void apiClient.getDocument(selectedRegulation.id).then(setSelectedRegulation); + } + }} + onEditMetadata={() => { + if (selectedRegulation) setMetadataRegulation(selectedRegulation); + }} + onViewReport={() => { + if (!selectedRegulation) return; + setAcknowledgementsReport({ + documentId: selectedRegulation.id, + documentTitle: selectedRegulation.title, + }); + }} + onNavigateToRelated={(documentId) => { + setSelectedRegulationInitialTab(undefined); + void apiClient.getDocument(documentId).then(setSelectedRegulation); + }} + /> + + {metadataRegulation ? ( + setMetadataRegulation(null)} + onUpdate={() => { + void refreshRegulations(); + if (selectedRegulation?.id === metadataRegulation.id) { + void apiClient.getDocument(metadataRegulation.id).then(setSelectedRegulation); + } + }} + /> + ) : null} + + {taskLinkRegulation ? ( + setTaskLinkRegulation(null)} + onLinked={() => void refreshRegulations()} + /> + ) : null} + + {regulationPreviewFile ? ( + setRegulationPreviewFile(null)} + /> + ) : null} + + {regulationPdfFile ? ( + setRegulationPdfFile(null)} + /> + ) : null} + + {acknowledgementsReport ? ( + setAcknowledgementsReport(null)} + /> + ) : null} + {taskLinkPost ? ( setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))} onAddItemComment={handleAddItemComment} onDeleteItemComment={handleDeleteItemComment} + onDownloadItemAttachment={handleDownloadItemAttachment} + onUploadItemAttachments={handleUploadItemAttachments} + onDeleteItemAttachment={handleDeleteItemAttachment} footer={( setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))} onAddItemComment={handleAddItemComment} onDeleteItemComment={handleDeleteItemComment} + onDownloadItemAttachment={handleDownloadItemAttachment} + onUploadItemAttachments={handleUploadItemAttachments} + onDeleteItemAttachment={handleDeleteItemAttachment} />
@@ -2227,6 +2237,77 @@ export default function ProcurementPage() {
))}
+
+
+ Файлы + +
+ {it.attachments.length > 0 || it.pending_files.length > 0 ? ( +
+ {it.attachments.map((attachment) => ( + + + + + ))} + {it.pending_files.map((file, fileIndex) => ( + + + {file.name} + + + ))} +
+ ) : ( +

Файлы не прикреплены

+ )} +
))} diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx index ad437562..e4f94019 100644 --- a/frontend/src/app/register/page.tsx +++ b/frontend/src/app/register/page.tsx @@ -95,12 +95,6 @@ export default function Register() { const file = e.target.files?.[0]; if (!file) return; - // Проверка размера (макс 10MB для исходного файла) - if (file.size > 10 * 1024 * 1024) { - setError('Размер файла не должен превышать 10MB'); - return; - } - // Проверка типа if (!file.type.startsWith('image/')) { setError('Выберите изображение'); diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index d93b7aec..15a1498b 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -17,14 +17,17 @@ import { import { CSS } from "@dnd-kit/utilities"; import { CalendarDays, + Check, ChevronDown, ChevronRight, + Download, + ExternalLink as ExternalLinkIcon, FileSignature, FileText, - GripVertical, History, Kanban, Link2, + ListChecks, Loader2, Maximize2, MessageSquare, @@ -32,6 +35,7 @@ import { Newspaper, Paperclip, Pencil, + Play, Plus, RefreshCw, Search, @@ -41,10 +45,12 @@ import { Trash2, UserRound, X, + type LucideIcon, } from "lucide-react"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { AppShell } from "@/components/AppShell"; +import { RequestAvatar } from "@/components/requests/RequestAvatar"; import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls"; import { Modal } from "@/components/ui"; import { useUser } from "@/contexts/UserContext"; @@ -53,13 +59,18 @@ import { displayUserName, formatDate, formatDateTime, formatMoney } from "@/lib/ import { resolveMediaUrl } from "@/lib/url"; import wsManager from "@/lib/websocketManager"; import type { + CalendarEvent, Department, + Document, Message, TaskActivity, + TaskAttachment, TaskBoard, TaskCard, + TaskChecklistItem, TaskColumn, TaskComment, + TaskExternalLink, TaskLinkedAttendanceRecord, TaskLinkedDocument, TaskLinkedEmployee, @@ -89,7 +100,7 @@ type BoardFormState = { id: number | null; name: string; description: string; - access: "all" | "restricted"; + access: "all" | "private" | "restricted"; member_ids: number[]; department_ids: number[]; }; @@ -100,6 +111,22 @@ type ColumnFormState = { is_done: boolean; }; +type LinkedItemMode = "document" | "calendar_event" | "external_link"; +type LinkedItemTarget = "view" | "form"; + +type TaskFormExternalLinkDraft = { + key: string; + url: string; + title: string; +}; + +type TaskFormChecklistItemDraft = { + key: string; + title: string; + position: number; + is_completed: boolean; +}; + type TaskBoardSocketEvent = { type: string; data?: { @@ -107,6 +134,7 @@ type TaskBoardSocketEvent = { event?: string; model?: string; object_id?: number | null; + task_id?: number; }; }; @@ -146,6 +174,15 @@ function TaskDescriptionPreview({ description }: { description: string }) { ); } +function getEmployeeInitials(employee: User): string { + const initials = [employee.first_name, employee.last_name] + .map((part) => part?.trim().charAt(0) || "") + .join("") + .slice(0, 2) + .toUpperCase(); + return initials || displayUserName(employee).slice(0, 2).toUpperCase(); +} + const emptyColumnForm: ColumnFormState = { name: "", color: "#38bdf8", @@ -207,6 +244,25 @@ function getTaskError(error: unknown, fallback: string) { return raw; } +function extractApiResults(response: T[] | { results?: T[] } | null | undefined): T[] { + if (Array.isArray(response)) return response; + return response?.results || []; +} + +function normalizeExternalUrl(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + return /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; +} + +function getExternalLinkHost(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} + function toDateOnlyTime(value?: string | null) { if (!value) return null; const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/); @@ -239,6 +295,14 @@ function getTaskDueDateBadgeClass(task: TaskCard, defaultClass = "app-badge") { return defaultClass; } +function formatFileSize(size: number): string { + if (size < 1024) return `${size} Б`; + if (size < 1024 * 1024) return `${Math.ceil(size / 1024)} КБ`; + return `${(size / (1024 * 1024)).toLocaleString("ru-RU", { + maximumFractionDigits: 1, + })} МБ`; +} + function getMessageCreatedLabel(message?: Message | null) { if (!message) return ""; return message.created || (message.created_at ? formatDate(message.created_at) : ""); @@ -249,6 +313,11 @@ function TaskCardView({ onOpen, onEdit, onDelete, + currentUserId, + claiming, + onClaim, + completing, + onComplete, menuOpen, menuRef, onToggleMenu, @@ -257,6 +326,11 @@ function TaskCardView({ onOpen: (task: TaskCard) => void; onEdit: (task: TaskCard) => void; onDelete: (task: TaskCard) => void; + currentUserId?: number; + claiming: boolean; + onClaim: (task: TaskCard) => void; + completing: boolean; + onComplete: (task: TaskCard) => void; menuOpen: boolean; menuRef: RefObject; onToggleMenu: () => void; @@ -268,31 +342,23 @@ function TaskCardView({ const style = transform && !isDragging ? { transform: CSS.Translate.toString(transform) } : undefined; const priority = priorityMeta[task.priority] ?? priorityMeta.medium; const dueDateClass = getTaskDueDateBadgeClass(task); + const canClaim = !task.assignee && !task.completed_at; + const canComplete = task.assignee?.id === currentUserId && !task.completed_at; return (
onOpen(task)} + {...attributes} + {...listeners} >
- - +
-
+
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + ) : null} + {canComplete ? ( + + ) : null} {task.assignee ? ( @@ -373,6 +484,18 @@ function TaskCardView({ {formatDate(task.due_date)} ) : null} + {(task.checklist_total || 0) > 0 ? ( + + + {task.checklist_completed || 0}/{task.checklist_total} + + ) : null} + {(task.attachments_count || 0) > 0 ? ( + + + {task.attachments_count} + + ) : null} {(task.linked_objects_count || task.linked_messages_count || 0) > 0 ? ( @@ -397,9 +520,6 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) { return (
-
- -
@@ -443,6 +563,18 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) { {formatDate(task.due_date)} ) : null} + {(task.checklist_total || 0) > 0 ? ( + + + {task.checklist_completed || 0}/{task.checklist_total} + + ) : null} + {(task.attachments_count || 0) > 0 ? ( + + + {task.attachments_count} + + ) : null} {(task.linked_objects_count || task.linked_messages_count || 0) > 0 ? ( @@ -467,6 +599,11 @@ function BoardColumn({ onOpenTask, onEditTask, onDeleteTask, + currentUserId, + claimingTaskId, + onClaimTask, + completingTaskId, + onCompleteTask, openMenuTaskId, menuRef, onToggleTaskMenu, @@ -479,6 +616,11 @@ function BoardColumn({ onOpenTask: (task: TaskCard) => void; onEditTask: (task: TaskCard) => void; onDeleteTask: (task: TaskCard) => void; + currentUserId?: number; + claimingTaskId: number | null; + onClaimTask: (task: TaskCard) => void; + completingTaskId: number | null; + onCompleteTask: (task: TaskCard) => void; openMenuTaskId: number | null; menuRef: RefObject; onToggleTaskMenu: (taskId: number) => void; @@ -533,6 +675,11 @@ function BoardColumn({ onOpen={onOpenTask} onEdit={onEditTask} onDelete={onDeleteTask} + currentUserId={currentUserId} + claiming={claimingTaskId === task.id} + onClaim={onClaimTask} + completing={completingTaskId === task.id} + onComplete={onCompleteTask} menuOpen={openMenuTaskId === task.id} menuRef={menuRef} onToggleMenu={() => onToggleTaskMenu(task.id)} @@ -1603,6 +1750,158 @@ function LinkedAttendanceRecordCard({ ); } +function TaskExternalLinkCard({ + link, + onDelete, + disabled, +}: { + link: TaskExternalLink; + onDelete: (linkId: number) => void; + disabled: boolean; +}) { + return ( + + ); +} + +function TaskFormResourceRow({ + icon: Icon, + title, + meta, + pending = false, + onRemove, +}: { + icon: LucideIcon; + title: string; + meta?: string; + pending?: boolean; + onRemove: () => void; +}) { + return ( +
+ + + + + + {title} + + {meta ? ( + + {meta} + + ) : null} + + {pending ? ( + + новое + + ) : null} + +
+ ); +} + + +function TaskChecklistFormRow({ + title, + isCompleted, + pending, + disabled, + onTitleChange, + onCompletedChange, + onRemove, +}: { + title: string; + isCompleted: boolean; + pending?: boolean; + disabled?: boolean; + onTitleChange: (value: string) => void; + onCompletedChange: (value: boolean) => void; + onRemove: () => void; +}) { + return ( +
+ onCompletedChange(event.target.checked)} + disabled={disabled} + className="h-4 w-4 shrink-0 accent-sky-500" + aria-label={isCompleted ? "Вернуть пункт в работу" : "Отметить пункт выполненным"} + /> + onTitleChange(event.target.value)} + disabled={disabled} + className={`app-input min-w-0 flex-1 rounded-lg px-2.5 py-1.5 text-sm ${ + isCompleted ? "line-through opacity-70" : "" + }`} + aria-label="Текст пункта чек-листа" + /> + {pending ? ( + + новое + + ) : null} + +
+ ); +} + + const activityFieldLabels: Record = { title: "название", description: "описание", @@ -1635,6 +1934,33 @@ function getActivityDetail(activity: TaskActivity): string { const column = typeof metadata.column === "string" ? metadata.column : ""; return column ? `Колонка: ${column}` : ""; } + if ( + activity.action === "attachment_added" || + activity.action === "attachment_removed" + ) { + return typeof metadata.file_name === "string" ? metadata.file_name : "Файл"; + } + if (activity.action.startsWith("checklist_item_")) { + return typeof metadata.item_title === "string" + ? metadata.item_title + : "Пункт чек-листа"; + } + if (activity.action === "comment_edited") { + const previousText = typeof metadata.previous_text === "string" + ? metadata.previous_text + : ""; + const commentText = typeof metadata.comment_text === "string" + ? metadata.comment_text + : ""; + if (previousText && commentText) { + return `Было: ${previousText}\nСтало: ${commentText}`; + } + } + if (activity.action === "comment_added" || activity.action === "comment_removed") { + return typeof metadata.comment_text === "string" + ? metadata.comment_text + : `Комментарий #${activity.object_id || ""}`; + } return ""; } @@ -1659,7 +1985,7 @@ function TaskActivityCard({ activity }: { activity: TaskActivity }) { {activity.actor ? displayUserName(activity.actor) : "Система"}

{detail ? ( -

+

{detail}

) : null} @@ -1689,6 +2015,8 @@ function TasksPageContent() { const [departments, setDepartments] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [claimingTaskId, setClaimingTaskId] = useState(null); + const [completingTaskId, setCompletingTaskId] = useState(null); const [error, setError] = useState(null); const [search, setSearch] = useState(""); const [onlyMine, setOnlyMine] = useState(false); @@ -1706,6 +2034,8 @@ function TasksPageContent() { const [columnModalOpen, setColumnModalOpen] = useState(false); const [form, setForm] = useState(emptyForm); const [boardForm, setBoardForm] = useState(emptyBoardForm); + const [boardMemberSearch, setBoardMemberSearch] = useState(""); + const [boardDepartmentSearch, setBoardDepartmentSearch] = useState(""); const [columnForm, setColumnForm] = useState(emptyColumnForm); const [labelName, setLabelName] = useState(""); const [labelColor, setLabelColor] = useState("#38bdf8"); @@ -1720,11 +2050,54 @@ function TasksPageContent() { const [linkedGuests, setLinkedGuests] = useState([]); const [linkedGuestVisits, setLinkedGuestVisits] = useState([]); const [linkedAttendanceRecords, setLinkedAttendanceRecords] = useState([]); + const [taskExternalLinks, setTaskExternalLinks] = useState([]); const [linkedObjectsLoading, setLinkedObjectsLoading] = useState(false); + const [linkedItemModalOpen, setLinkedItemModalOpen] = useState(false); + const [linkedItemMode, setLinkedItemMode] = useState("document"); + const [linkedItemTarget, setLinkedItemTarget] = useState("view"); + const [linkedItemSearch, setLinkedItemSearch] = useState(""); + const [linkedItemCandidatesLoading, setLinkedItemCandidatesLoading] = useState(false); + const [availableDocuments, setAvailableDocuments] = useState([]); + const [availableEvents, setAvailableEvents] = useState([]); + const [externalLinkUrl, setExternalLinkUrl] = useState(""); + const [externalLinkTitle, setExternalLinkTitle] = useState(""); + const [taskFormFilesOpen, setTaskFormFilesOpen] = useState(false); + const [taskFormLinksOpen, setTaskFormLinksOpen] = useState(false); + const [taskFormChecklistOpen, setTaskFormChecklistOpen] = useState(false); + const [taskFormChecklistDraft, setTaskFormChecklistDraft] = useState(""); + const [taskFormResourcesLoading, setTaskFormResourcesLoading] = useState(false); + const [taskFormChecklistItems, setTaskFormChecklistItems] = useState([]); + const [taskFormPendingChecklistItems, setTaskFormPendingChecklistItems] = useState([]); + const [taskFormRemovedChecklistItemIds, setTaskFormRemovedChecklistItemIds] = useState([]); + const [taskFormDirtyChecklistItemIds, setTaskFormDirtyChecklistItemIds] = useState([]); + const [taskFormAttachments, setTaskFormAttachments] = useState([]); + const [taskFormDocuments, setTaskFormDocuments] = useState([]); + const [taskFormEvents, setTaskFormEvents] = useState([]); + const [taskFormExternalLinks, setTaskFormExternalLinks] = useState([]); + const [taskFormPendingFiles, setTaskFormPendingFiles] = useState([]); + const [taskFormPendingDocuments, setTaskFormPendingDocuments] = useState([]); + const [taskFormPendingEvents, setTaskFormPendingEvents] = useState([]); + const [taskFormPendingExternalLinks, setTaskFormPendingExternalLinks] = useState([]); + const [taskFormRemovedAttachmentIds, setTaskFormRemovedAttachmentIds] = useState([]); + const [taskFormRemovedDocumentLinkIds, setTaskFormRemovedDocumentLinkIds] = useState([]); + const [taskFormRemovedEventLinkIds, setTaskFormRemovedEventLinkIds] = useState([]); + const [taskFormRemovedExternalLinkIds, setTaskFormRemovedExternalLinkIds] = useState([]); const [commentsOpen, setCommentsOpen] = useState(false); const [taskComments, setTaskComments] = useState([]); + const [editingCommentId, setEditingCommentId] = useState(null); + const [editingCommentDraft, setEditingCommentDraft] = useState(""); const [taskCommentsLoading, setTaskCommentsLoading] = useState(false); const [commentDraft, setCommentDraft] = useState(""); + const [attachmentsOpen, setAttachmentsOpen] = useState(false); + const [taskAttachments, setTaskAttachments] = useState([]); + const [taskAttachmentsLoading, setTaskAttachmentsLoading] = useState(false); + const [taskAttachmentsUploading, setTaskAttachmentsUploading] = useState(false); + const [checklistOpen, setChecklistOpen] = useState(false); + const [taskChecklist, setTaskChecklist] = useState([]); + const [taskChecklistLoaded, setTaskChecklistLoaded] = useState(false); + const [taskChecklistLoading, setTaskChecklistLoading] = useState(false); + const [taskChecklistSaving, setTaskChecklistSaving] = useState(false); + const [taskChecklistDraft, setTaskChecklistDraft] = useState(""); const [activityOpen, setActivityOpen] = useState(false); const [taskActivities, setTaskActivities] = useState([]); const [taskActivityLoading, setTaskActivityLoading] = useState(false); @@ -1737,6 +2110,11 @@ function TasksPageContent() { const taskBoardSyncTimerRef = useRef | null>(null); const boardScrollRef = useRef(null); const columnNodeRefs = useRef>(new Map()); + const taskAttachmentInputRef = useRef(null); + const taskFormAttachmentInputRef = useRef(null); + const taskChecklistDraftRef = useRef(null); + const taskFormChecklistDraftRef = useRef(null); + const taskFormResourcesRequestRef = useRef(0); const sensors = useSensors( useSensor(PointerSensor, { @@ -1792,6 +2170,7 @@ function TasksPageContent() { guests, guestVisits, attendanceRecords, + externalLinks, ] = await Promise.all([ apiClient.getTaskLinkedPosts(taskId), apiClient.getTaskLinkedMessages(taskId), @@ -1803,6 +2182,7 @@ function TasksPageContent() { apiClient.getTaskLinkedGuests(taskId), apiClient.getTaskLinkedGuestVisits(taskId), apiClient.getTaskLinkedAttendanceRecords(taskId), + apiClient.getTaskExternalLinks(taskId), ]); setLinkedPosts(posts); setLinkedMessages(messages); @@ -1814,6 +2194,7 @@ function TasksPageContent() { setLinkedGuests(guests); setLinkedGuestVisits(guestVisits); setLinkedAttendanceRecords(attendanceRecords); + setTaskExternalLinks(externalLinks); } catch (linksError) { setError(getTaskError(linksError, "Не удалось загрузить связанные объекты")); } finally { @@ -1833,6 +2214,83 @@ function TasksPageContent() { } }, []); + const loadTaskAttachments = useCallback(async (taskId: number) => { + setTaskAttachmentsLoading(true); + try { + const data = await apiClient.getTaskAttachments(taskId); + setTaskAttachments(data); + } catch (attachmentsError) { + setError(getTaskError(attachmentsError, "Не удалось загрузить файлы задачи")); + } finally { + setTaskAttachmentsLoading(false); + } + }, []); + + const loadTaskChecklist = useCallback(async (taskId: number) => { + setTaskChecklistLoading(true); + try { + const data = await apiClient.getTaskChecklist(taskId); + setTaskChecklist(data); + setTaskChecklistLoaded(true); + } catch (checklistError) { + setError(getTaskError(checklistError, "Не удалось загрузить чек-лист")); + } finally { + setTaskChecklistLoading(false); + } + }, []); + + const clearTaskFormResources = useCallback(() => { + setTaskFormFilesOpen(false); + setTaskFormLinksOpen(false); + setTaskFormChecklistOpen(false); + setTaskFormChecklistDraft(""); + setTaskFormResourcesLoading(false); + setTaskFormChecklistItems([]); + setTaskFormPendingChecklistItems([]); + setTaskFormRemovedChecklistItemIds([]); + setTaskFormDirtyChecklistItemIds([]); + setTaskFormAttachments([]); + setTaskFormDocuments([]); + setTaskFormEvents([]); + setTaskFormExternalLinks([]); + setTaskFormPendingFiles([]); + setTaskFormPendingDocuments([]); + setTaskFormPendingEvents([]); + setTaskFormPendingExternalLinks([]); + setTaskFormRemovedAttachmentIds([]); + setTaskFormRemovedDocumentLinkIds([]); + setTaskFormRemovedEventLinkIds([]); + setTaskFormRemovedExternalLinkIds([]); + }, []); + + const loadTaskFormResources = useCallback(async (taskId: number) => { + const requestId = ++taskFormResourcesRequestRef.current; + setTaskFormResourcesLoading(true); + try { + const [checklist, attachments, documents, events, externalLinks] = await Promise.all([ + apiClient.getTaskChecklist(taskId), + apiClient.getTaskAttachments(taskId), + apiClient.getTaskLinkedDocuments(taskId), + apiClient.getTaskLinkedEvents(taskId), + apiClient.getTaskExternalLinks(taskId), + ]); + if (requestId !== taskFormResourcesRequestRef.current) return; + setTaskFormChecklistItems(checklist); + setTaskFormAttachments(attachments); + setTaskFormDocuments(documents); + setTaskFormEvents(events); + setTaskFormExternalLinks(externalLinks); + } catch (resourcesError) { + if (requestId === taskFormResourcesRequestRef.current) { + setError(getTaskError(resourcesError, "Не удалось загрузить файлы и связи задачи")); + } + } finally { + if (requestId === taskFormResourcesRequestRef.current) { + setTaskFormResourcesLoading(false); + } + } + }, []); + const loadTaskComments = useCallback(async (taskId: number) => { setTaskCommentsLoading(true); try { @@ -1845,6 +2303,49 @@ function TasksPageContent() { } }, []); + useEffect(() => { + if (!linkedItemModalOpen || linkedItemMode === "external_link") { + setLinkedItemCandidatesLoading(false); + return; + } + + let cancelled = false; + const timer = window.setTimeout(async () => { + setLinkedItemCandidatesLoading(true); + try { + const searchValue = linkedItemSearch.trim() || undefined; + if (linkedItemMode === "document") { + const response = await apiClient.getDocuments({ + search: searchValue, + page_size: 100, + }); + if (!cancelled) { + setAvailableDocuments(extractApiResults(response)); + } + } else { + const response = await apiClient.getCalendarEvents({ + search: searchValue, + page_size: 100, + }); + if (!cancelled) { + setAvailableEvents(extractApiResults(response)); + } + } + } catch (candidatesError) { + if (!cancelled) { + setError(getTaskError(candidatesError, "Не удалось загрузить доступные объекты")); + } + } finally { + if (!cancelled) setLinkedItemCandidatesLoading(false); + } + }, 250); + + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [linkedItemModalOpen, linkedItemMode, linkedItemSearch]); + useEffect(() => { selectedBoardIdRef.current = selectedBoardId; }, [selectedBoardId]); @@ -1938,6 +2439,37 @@ function TasksPageContent() { const boardId = Number(data.data?.board_id); if (!Number.isFinite(boardId) || boardId <= 0) return; + const eventTaskId = Number( + data.data?.task_id ?? ( + data.data?.model === "task" ? data.data?.object_id : undefined + ), + ); + if ( + data.data?.model === "checklist_item" && + checklistOpen && + viewTaskId && + eventTaskId === viewTaskId + ) { + void loadTaskChecklist(viewTaskId); + } + + if ( + commentsOpen && + viewTaskId && + eventTaskId === viewTaskId && + ( + data.data?.event === "commented" || + data.data?.event === "comment_edited" || + data.data?.event === "comment_deleted" + ) + ) { + void loadTaskComments(viewTaskId); + } + + if (activityOpen && viewTaskId && eventTaskId === viewTaskId) { + void loadTaskActivity(viewTaskId); + } + if (taskBoardSyncTimerRef.current) { clearTimeout(taskBoardSyncTimerRef.current); } @@ -1954,7 +2486,16 @@ function TasksPageContent() { taskBoardSyncTimerRef.current = null; } }; - }, [syncTaskBoardState]); + }, [ + activityOpen, + checklistOpen, + commentsOpen, + loadTaskActivity, + loadTaskChecklist, + loadTaskComments, + syncTaskBoardState, + viewTaskId, + ]); useEffect(() => { let mounted = true; @@ -2078,6 +2619,30 @@ function TasksPageContent() { return () => document.removeEventListener("mousedown", handlePointerDown); }, [cardMenuTaskId]); + const filteredBoardMembers = useMemo(() => { + const query = boardMemberSearch.trim().toLowerCase(); + if (!query) return employees; + return employees.filter((employee) => { + const searchable = [ + displayUserName(employee), + employee.email, + employee.position?.name, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return searchable.includes(query); + }); + }, [boardMemberSearch, employees]); + + const filteredBoardDepartments = useMemo(() => { + const query = boardDepartmentSearch.trim().toLowerCase(); + if (!query) return departments; + return departments.filter((department) => ( + department.name.toLowerCase().includes(query) + )); + }, [boardDepartmentSearch, departments]); + const searchedTasks = useMemo(() => { const tasks = board?.tasks || []; const query = search.trim().toLowerCase(); @@ -2146,9 +2711,34 @@ function TasksPageContent() { () => activeColumns.find((column) => column.id === viewTask?.column) || null, [activeColumns, viewTask?.column], ); - const linkedObjectsCount = linkedPosts.length + linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length + linkedGuests.length + linkedGuestVisits.length + linkedAttendanceRecords.length; + const linkedObjectsCount = linkedPosts.length + linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length + linkedGuests.length + linkedGuestVisits.length + linkedAttendanceRecords.length + taskExternalLinks.length; const linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount; const commentsBadgeCount = viewTask?.comments_count ?? taskComments.length; + const attachmentsBadgeCount = viewTask?.attachments_count ?? taskAttachments.length; + const checklistBadgeTotal = taskChecklistLoaded + ? taskChecklist.length + : (viewTask?.checklist_total || 0); + const checklistBadgeCompleted = taskChecklistLoaded + ? taskChecklist.filter((item) => item.is_completed).length + : (viewTask?.checklist_completed || 0); + const taskFormChecklistTotal = taskFormChecklistItems.length + taskFormPendingChecklistItems.length; + const taskFormChecklistCompleted = ( + taskFormChecklistItems.filter((item) => item.is_completed).length + + taskFormPendingChecklistItems.filter((item) => item.is_completed).length + ); + const taskFormChecklistHasInvalidTitle = [ + ...taskFormChecklistItems.map((item) => item.title), + ...taskFormPendingChecklistItems.map((item) => item.title), + ].some((title) => !title.trim()); + const taskFormAttachmentsCount = taskFormAttachments.length + taskFormPendingFiles.length; + const taskFormLinksCount = ( + taskFormDocuments.length + + taskFormEvents.length + + taskFormExternalLinks.length + + taskFormPendingDocuments.length + + taskFormPendingEvents.length + + taskFormPendingExternalLinks.length + ); useEffect(() => { if ( @@ -2171,12 +2761,20 @@ function TasksPageContent() { setLinkedGuests([]); setLinkedGuestVisits([]); setLinkedAttendanceRecords([]); + setTaskExternalLinks([]); setTaskActivities([]); setTaskComments([]); + setTaskAttachments([]); + setTaskChecklist([]); + setTaskChecklistLoaded(false); + setTaskChecklistDraft(""); setCommentDraft(""); setLinkedObjectsOpen(false); setCommentsOpen(false); + setAttachmentsOpen(false); + setChecklistOpen(false); setActivityOpen(false); + setLinkedItemModalOpen(false); return; } @@ -2190,6 +2788,16 @@ function TasksPageContent() { void loadTaskComments(viewTaskId); }, [commentsOpen, loadTaskComments, viewTask?.comments_count, viewTaskId]); + useEffect(() => { + if (!viewTaskId || !attachmentsOpen) return; + void loadTaskAttachments(viewTaskId); + }, [attachmentsOpen, loadTaskAttachments, viewTask?.attachments_count, viewTaskId]); + + useEffect(() => { + if (!viewTaskId || !checklistOpen) return; + void loadTaskChecklist(viewTaskId); + }, [checklistOpen, loadTaskChecklist, viewTaskId]); + useEffect(() => { if (!viewTaskId || !activityOpen) return; void loadTaskActivity(viewTaskId); @@ -2197,6 +2805,8 @@ function TasksPageContent() { const openCreateTask = useCallback((columnId?: number) => { const firstColumn = board?.columns.find((column) => !column.is_archived); + taskFormResourcesRequestRef.current += 1; + clearTaskFormResources(); setForm({ ...emptyForm, column: columnId || firstColumn?.id || "", @@ -2204,7 +2814,7 @@ function TasksPageContent() { setLabelName(""); setLabelColor("#38bdf8"); setTaskModalOpen(true); - }, [board?.columns]); + }, [board?.columns, clearTaskFormResources]); const openTaskView = useCallback((task: TaskCard) => { setViewTaskId(task.id); @@ -2212,6 +2822,8 @@ function TasksPageContent() { setViewColumnMenuOpen(false); setLinkedObjectsOpen(false); setCommentsOpen(false); + setAttachmentsOpen(false); + setChecklistOpen(true); setActivityOpen(false); setLinkedMessages([]); setLinkedEvents([]); @@ -2223,6 +2835,12 @@ function TasksPageContent() { setLinkedGuestVisits([]); setLinkedAttendanceRecords([]); setTaskComments([]); + setEditingCommentId(null); + setEditingCommentDraft(""); + setTaskAttachments([]); + setTaskChecklist([]); + setTaskChecklistLoaded(false); + setTaskChecklistDraft(""); setTaskActivities([]); setCommentDraft(""); setCardMenuTaskId(null); @@ -2233,7 +2851,7 @@ function TasksPageContent() { }, []); const closeTaskView = useCallback(() => { - if (saving) return; + if (saving || taskAttachmentsUploading || taskChecklistSaving) return; setViewTaskId(null); setTaskMenuOpen(false); setViewColumnMenuOpen(false); @@ -2247,14 +2865,24 @@ function TasksPageContent() { setLinkedGuestVisits([]); setLinkedAttendanceRecords([]); setTaskComments([]); + setEditingCommentId(null); + setEditingCommentDraft(""); + setTaskAttachments([]); + setTaskChecklist([]); + setTaskChecklistLoaded(false); setTaskActivities([]); setLinkedObjectsOpen(false); setCommentsOpen(false); + setAttachmentsOpen(false); + setChecklistOpen(false); setActivityOpen(false); setCommentDraft(""); - }, [saving]); + setTaskChecklistDraft(""); + }, [saving, taskAttachmentsUploading, taskChecklistSaving]); const openEditTask = useCallback((task: TaskCard) => { + taskFormResourcesRequestRef.current += 1; + clearTaskFormResources(); setForm({ id: task.id, title: task.title, @@ -2272,17 +2900,22 @@ function TasksPageContent() { setViewColumnMenuOpen(false); setCardMenuTaskId(null); setTaskModalOpen(true); - }, []); + void loadTaskFormResources(task.id); + }, [clearTaskFormResources, loadTaskFormResources]); const closeTaskModal = useCallback(() => { if (saving) return; + taskFormResourcesRequestRef.current += 1; setTaskModalOpen(false); setForm(emptyForm); setLabelName(""); - }, [saving]); + clearTaskFormResources(); + }, [clearTaskFormResources, saving]); const openCreateBoard = useCallback(() => { setBoardForm(emptyBoardForm); + setBoardMemberSearch(""); + setBoardDepartmentSearch(""); setBoardActionsMenuOpen(false); setBoardModalOpen(true); }, []); @@ -2295,10 +2928,14 @@ function TasksPageContent() { id: board.id, name: board.name || "", description: board.description || "", - access: memberIds.length > 0 || departmentIds.length > 0 ? "restricted" : "all", + access: board.access_scope || ( + memberIds.length > 0 || departmentIds.length > 0 ? "restricted" : "all" + ), member_ids: memberIds, department_ids: departmentIds, }); + setBoardMemberSearch(""); + setBoardDepartmentSearch(""); setBoardActionsMenuOpen(false); setBoardModalOpen(true); }, [board]); @@ -2307,6 +2944,8 @@ function TasksPageContent() { if (saving) return; setBoardModalOpen(false); setBoardForm(emptyBoardForm); + setBoardMemberSearch(""); + setBoardDepartmentSearch(""); }, [saving]); const openCreateColumn = useCallback(() => { @@ -2427,6 +3066,7 @@ function TasksPageContent() { const payload = { name: boardForm.name.trim(), description: boardForm.description.trim(), + access_scope: boardForm.access, members: boardForm.access === "restricted" ? boardForm.member_ids : [], departments: boardForm.access === "restricted" ? boardForm.department_ids : [], }; @@ -2482,8 +3122,110 @@ function TasksPageContent() { } }, [board, columnForm.color, columnForm.is_done, columnForm.name, loadBoard]); + const persistTaskFormResources = useCallback(async (taskId: number) => { + for (const itemId of [...taskFormRemovedChecklistItemIds]) { + await apiClient.deleteTaskChecklistItem(taskId, itemId); + setTaskFormRemovedChecklistItemIds((current) => current.filter((id) => id !== itemId)); + } + + for (const itemId of [...taskFormDirtyChecklistItemIds]) { + const item = taskFormChecklistItems.find((currentItem) => currentItem.id === itemId); + if (!item) continue; + const updated = await apiClient.updateTaskChecklistItem(taskId, item.id, { + title: item.title.trim(), + is_completed: item.is_completed, + }); + setTaskFormDirtyChecklistItemIds((current) => current.filter((id) => id !== itemId)); + setTaskFormChecklistItems((current) => current.map((currentItem) => ( + currentItem.id === updated.id ? updated : currentItem + ))); + } + + for (const item of [...taskFormPendingChecklistItems]) { + const created = await apiClient.addTaskChecklistItem(taskId, { + title: item.title.trim(), + position: item.position, + is_completed: item.is_completed, + }); + setTaskFormPendingChecklistItems((current) => ( + current.filter((currentItem) => currentItem.key !== item.key) + )); + setTaskFormChecklistItems((current) => [...current, created]); + } + + for (const attachmentId of [...taskFormRemovedAttachmentIds]) { + await apiClient.deleteTaskAttachment(taskId, attachmentId); + setTaskFormRemovedAttachmentIds((current) => ( + current.filter((id) => id !== attachmentId) + )); + } + + for (const linkId of [...taskFormRemovedDocumentLinkIds]) { + await apiClient.unlinkTaskDocument(taskId, linkId); + setTaskFormRemovedDocumentLinkIds((current) => current.filter((id) => id !== linkId)); + } + + for (const linkId of [...taskFormRemovedEventLinkIds]) { + await apiClient.unlinkTaskCalendarEvent(taskId, linkId); + setTaskFormRemovedEventLinkIds((current) => current.filter((id) => id !== linkId)); + } + + for (const linkId of [...taskFormRemovedExternalLinkIds]) { + await apiClient.deleteTaskExternalLink(taskId, linkId); + setTaskFormRemovedExternalLinkIds((current) => current.filter((id) => id !== linkId)); + } + + for (const document of [...taskFormPendingDocuments]) { + const linked = await apiClient.linkTaskDocument(taskId, document.id); + setTaskFormPendingDocuments((current) => current.filter((item) => item.id !== document.id)); + setTaskFormDocuments((current) => ( + current.some((item) => item.id === linked.id) ? current : [...current, linked] + )); + } + + for (const event of [...taskFormPendingEvents]) { + const linked = await apiClient.linkTaskCalendarEvent(taskId, event.id); + setTaskFormPendingEvents((current) => current.filter((item) => item.id !== event.id)); + setTaskFormEvents((current) => ( + current.some((item) => item.id === linked.id) ? current : [...current, linked] + )); + } + + for (const link of [...taskFormPendingExternalLinks]) { + const created = await apiClient.addTaskExternalLink(taskId, { + url: link.url, + title: link.title || undefined, + }); + setTaskFormPendingExternalLinks((current) => ( + current.filter((item) => item.key !== link.key) + )); + setTaskFormExternalLinks((current) => ( + current.some((item) => item.id === created.id) ? current : [...current, created] + )); + } + + for (const file of [...taskFormPendingFiles]) { + const uploaded = await apiClient.uploadTaskAttachments(taskId, [file]); + setTaskFormPendingFiles((current) => current.filter((item) => item !== file)); + setTaskFormAttachments((current) => [...current, ...uploaded]); + } + }, [ + taskFormChecklistItems, + taskFormDirtyChecklistItemIds, + taskFormPendingDocuments, + taskFormPendingEvents, + taskFormPendingExternalLinks, + taskFormPendingFiles, + taskFormPendingChecklistItems, + taskFormRemovedAttachmentIds, + taskFormRemovedChecklistItemIds, + taskFormRemovedDocumentLinkIds, + taskFormRemovedEventLinkIds, + taskFormRemovedExternalLinkIds, + ]); + const saveTask = useCallback(async () => { - if (!board || !form.title.trim() || !form.column) return; + if (!board || !form.title.trim() || !form.column || taskFormChecklistHasInvalidTitle) return; setSaving(true); setError(null); const payload = { @@ -2496,20 +3238,46 @@ function TasksPageContent() { due_date: form.due_date || null, label_ids: form.label_ids, }; + let savedTaskId = form.id || null; + let primaryDataSaved = false; try { - if (form.id) { - await apiClient.updateTask(form.id, payload); - } else { - await apiClient.createTask(payload); + const savedTask = form.id + ? await apiClient.updateTask(form.id, payload) + : await apiClient.createTask(payload); + savedTaskId = savedTask.id; + primaryDataSaved = true; + if (!form.id) { + setForm((current) => ({ ...current, id: savedTask.id })); } + + await persistTaskFormResources(savedTask.id); await loadBoard(board.id); closeTaskModal(); } catch (saveError) { - setError(getTaskError(saveError, "Не удалось сохранить задачу")); + if (primaryDataSaved && savedTaskId) { + try { + await loadBoard(board.id); + } catch { + // Исходная ошибка сохранения ресурса важнее ошибки обновления доски. + } + setError(getTaskError( + saveError, + "Основные данные сохранены, но не удалось сохранить чек-лист, файлы или связи", + )); + } else { + setError(getTaskError(saveError, "Не удалось сохранить задачу")); + } } finally { setSaving(false); } - }, [board, closeTaskModal, form, loadBoard]); + }, [ + board, + closeTaskModal, + form, + loadBoard, + persistTaskFormResources, + taskFormChecklistHasInvalidTitle, + ]); const createLabel = useCallback(async () => { if (!board || !labelName.trim()) return; @@ -2570,6 +3338,381 @@ function TasksPageContent() { await deleteTask(viewTask); }, [deleteTask, viewTask]); + const uploadViewedTaskAttachments = useCallback(async (files: File[]) => { + if (!board || !viewTask || taskAttachmentsUploading || files.length === 0) return; + + setTaskAttachmentsUploading(true); + setError(null); + try { + const created = await apiClient.uploadTaskAttachments(viewTask.id, files); + setTaskAttachments((current) => [...current, ...created]); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + } catch (attachmentError) { + setError(getTaskError(attachmentError, "Не удалось загрузить файлы")); + } finally { + setTaskAttachmentsUploading(false); + } + }, [activityOpen, board, loadBoard, loadTaskActivity, taskAttachmentsUploading, viewTask]); + + const downloadViewedTaskAttachment = useCallback(async (attachment: TaskAttachment) => { + if (!viewTask) return; + setError(null); + try { + const { blob, filename } = await apiClient.downloadTaskAttachment( + viewTask.id, + attachment, + ); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } catch (attachmentError) { + setError(getTaskError(attachmentError, "Не удалось скачать файл")); + } + }, [viewTask]); + + const deleteViewedTaskAttachment = useCallback(async (attachment: TaskAttachment) => { + if (!board || !viewTask || taskAttachmentsUploading) return; + if (!window.confirm(`Удалить файл "${attachment.file_name}"?`)) return; + + setTaskAttachmentsUploading(true); + setError(null); + try { + await apiClient.deleteTaskAttachment(viewTask.id, attachment.id); + setTaskAttachments((current) => current.filter((item) => item.id !== attachment.id)); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + } catch (attachmentError) { + setError(getTaskError(attachmentError, "Не удалось удалить файл")); + } finally { + setTaskAttachmentsUploading(false); + } + }, [activityOpen, board, loadBoard, loadTaskActivity, taskAttachmentsUploading, viewTask]); + + const addViewedTaskChecklistItem = useCallback(async () => { + const title = taskChecklistDraft.trim(); + if (!board || !viewTask || !title || taskChecklistSaving) return; + + setTaskChecklistSaving(true); + setError(null); + try { + const created = await apiClient.addTaskChecklistItem(viewTask.id, { title }); + setTaskChecklist((current) => [ + ...current.filter((item) => item.id !== created.id), + created, + ].sort((left, right) => left.position - right.position || left.id - right.id)); + setTaskChecklistLoaded(true); + setTaskChecklistDraft(""); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + window.requestAnimationFrame(() => taskChecklistDraftRef.current?.focus()); + } catch (checklistError) { + setError(getTaskError(checklistError, "Не удалось добавить пункт чек-листа")); + } finally { + setTaskChecklistSaving(false); + } + }, [ + activityOpen, + board, + loadBoard, + loadTaskActivity, + taskChecklistDraft, + taskChecklistSaving, + viewTask, + ]); + + const toggleViewedTaskChecklistItem = useCallback(async (item: TaskChecklistItem) => { + if (!board || !viewTask || taskChecklistSaving) return; + + setTaskChecklistSaving(true); + setError(null); + try { + const updated = await apiClient.updateTaskChecklistItem(viewTask.id, item.id, { + is_completed: !item.is_completed, + }); + setTaskChecklist((current) => current.map((currentItem) => ( + currentItem.id === updated.id ? updated : currentItem + ))); + setTaskChecklistLoaded(true); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + } catch (checklistError) { + setError(getTaskError(checklistError, "Не удалось изменить пункт чек-листа")); + } finally { + setTaskChecklistSaving(false); + } + }, [activityOpen, board, loadBoard, loadTaskActivity, taskChecklistSaving, viewTask]); + + const deleteViewedTaskChecklistItem = useCallback(async (item: TaskChecklistItem) => { + if (!board || !viewTask || taskChecklistSaving) return; + + setTaskChecklistSaving(true); + setError(null); + try { + await apiClient.deleteTaskChecklistItem(viewTask.id, item.id); + setTaskChecklist((current) => current.filter((currentItem) => currentItem.id !== item.id)); + setTaskChecklistLoaded(true); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + } catch (checklistError) { + setError(getTaskError(checklistError, "Не удалось удалить пункт чек-листа")); + } finally { + setTaskChecklistSaving(false); + } + }, [activityOpen, board, loadBoard, loadTaskActivity, taskChecklistSaving, viewTask]); + + const addTaskFormChecklistItem = useCallback(() => { + const title = taskFormChecklistDraft.trim(); + if (!title) return; + const maxPosition = Math.max( + 0, + ...taskFormChecklistItems.map((item) => item.position), + ...taskFormPendingChecklistItems.map((item) => item.position), + ); + setTaskFormPendingChecklistItems((current) => [ + ...current, + { + key: `checklist-${Date.now()}-${Math.random().toString(36).slice(2)}`, + title, + position: maxPosition + 1000, + is_completed: false, + }, + ]); + setTaskFormChecklistDraft(""); + setTaskFormChecklistOpen(true); + window.requestAnimationFrame(() => taskFormChecklistDraftRef.current?.focus()); + }, [taskFormChecklistDraft, taskFormChecklistItems, taskFormPendingChecklistItems]); + + const updateExistingTaskFormChecklistItem = useCallback(( + itemId: number, + patch: Partial>, + ) => { + setTaskFormChecklistItems((current) => current.map((item) => ( + item.id === itemId ? { ...item, ...patch } : item + ))); + setTaskFormDirtyChecklistItemIds((current) => ( + current.includes(itemId) ? current : [...current, itemId] + )); + }, []); + + const updatePendingTaskFormChecklistItem = useCallback(( + key: string, + patch: Partial>, + ) => { + setTaskFormPendingChecklistItems((current) => current.map((item) => ( + item.key === key ? { ...item, ...patch } : item + ))); + }, []); + + const removeExistingTaskFormChecklistItem = useCallback((item: TaskChecklistItem) => { + setTaskFormChecklistItems((current) => current.filter((currentItem) => currentItem.id !== item.id)); + setTaskFormRemovedChecklistItemIds((current) => ( + current.includes(item.id) ? current : [...current, item.id] + )); + setTaskFormDirtyChecklistItemIds((current) => current.filter((id) => id !== item.id)); + }, []); + + const addFilesToTaskForm = useCallback((files: File[]) => { + if (files.length === 0) return; + setTaskFormFilesOpen(true); + setTaskFormPendingFiles((current) => { + const keys = new Set( + current.map((file) => `${file.name}:${file.size}:${file.lastModified}`), + ); + return [ + ...current, + ...files.filter((file) => { + const key = `${file.name}:${file.size}:${file.lastModified}`; + if (keys.has(key)) return false; + keys.add(key); + return true; + }), + ]; + }); + }, []); + + const removeExistingTaskFormAttachment = useCallback((attachment: TaskAttachment) => { + setTaskFormAttachments((current) => current.filter((item) => item.id !== attachment.id)); + setTaskFormRemovedAttachmentIds((current) => [...current, attachment.id]); + }, []); + + const removeExistingTaskFormDocument = useCallback((link: TaskLinkedDocument) => { + setTaskFormDocuments((current) => current.filter((item) => item.id !== link.id)); + setTaskFormRemovedDocumentLinkIds((current) => [...current, link.id]); + }, []); + + const removeExistingTaskFormEvent = useCallback((link: TaskLinkedCalendarEvent) => { + setTaskFormEvents((current) => current.filter((item) => item.id !== link.id)); + setTaskFormRemovedEventLinkIds((current) => [...current, link.id]); + }, []); + + const removeExistingTaskFormExternalLink = useCallback((link: TaskExternalLink) => { + setTaskFormExternalLinks((current) => current.filter((item) => item.id !== link.id)); + setTaskFormRemovedExternalLinkIds((current) => [...current, link.id]); + }, []); + + const openLinkedItemModalForView = useCallback(() => { + if (!viewTask) return; + setLinkedItemTarget("view"); + setLinkedItemMode("document"); + setLinkedItemSearch(""); + setExternalLinkUrl(""); + setExternalLinkTitle(""); + setLinkedObjectsOpen(true); + setLinkedItemModalOpen(true); + }, [viewTask]); + + const openLinkedItemModalForForm = useCallback(() => { + if (!taskModalOpen) return; + setLinkedItemTarget("form"); + setLinkedItemMode("document"); + setLinkedItemSearch(""); + setExternalLinkUrl(""); + setExternalLinkTitle(""); + setTaskFormLinksOpen(true); + setLinkedItemModalOpen(true); + }, [taskModalOpen]); + + const closeLinkedItemModal = useCallback(() => { + if (saving) return; + setLinkedItemModalOpen(false); + setLinkedItemSearch(""); + setExternalLinkUrl(""); + setExternalLinkTitle(""); + }, [saving]); + + const refreshViewedTaskLinks = useCallback(async () => { + if (!board || !viewTask) return; + await Promise.all([ + loadTaskLinkedObjects(viewTask.id), + loadBoard(board.id), + ]); + if (activityOpen) await loadTaskActivity(viewTask.id); + }, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, viewTask]); + + const linkDocumentToViewedTask = useCallback(async (documentId: number) => { + if (!viewTask || saving) return; + setSaving(true); + setError(null); + try { + await apiClient.linkTaskDocument(viewTask.id, documentId); + await refreshViewedTaskLinks(); + setLinkedItemModalOpen(false); + } catch (linkError) { + setError(getTaskError(linkError, "Не удалось связать документ с задачей")); + } finally { + setSaving(false); + } + }, [refreshViewedTaskLinks, saving, viewTask]); + + const linkEventToViewedTask = useCallback(async (eventId: number) => { + if (!viewTask || saving) return; + setSaving(true); + setError(null); + try { + await apiClient.linkTaskCalendarEvent(viewTask.id, eventId); + await refreshViewedTaskLinks(); + setLinkedItemModalOpen(false); + } catch (linkError) { + setError(getTaskError(linkError, "Не удалось связать событие с задачей")); + } finally { + setSaving(false); + } + }, [refreshViewedTaskLinks, saving, viewTask]); + + const addExternalLinkToViewedTask = useCallback(async () => { + if (!viewTask || saving) return; + const url = normalizeExternalUrl(externalLinkUrl); + if (!url) return; + + setSaving(true); + setError(null); + try { + await apiClient.addTaskExternalLink(viewTask.id, { + url, + title: externalLinkTitle.trim() || undefined, + }); + await refreshViewedTaskLinks(); + setLinkedItemModalOpen(false); + setExternalLinkUrl(""); + setExternalLinkTitle(""); + } catch (linkError) { + setError(getTaskError(linkError, "Не удалось добавить ссылку")); + } finally { + setSaving(false); + } + }, [externalLinkTitle, externalLinkUrl, refreshViewedTaskLinks, saving, viewTask]); + + const addDocumentToTaskForm = useCallback((document: Document) => { + const isExisting = taskFormDocuments.some((link) => link.document_id === document.id); + const isPending = taskFormPendingDocuments.some((item) => item.id === document.id); + if (isExisting || isPending) return; + setTaskFormPendingDocuments((current) => [...current, document]); + setTaskFormLinksOpen(true); + setLinkedItemModalOpen(false); + }, [taskFormDocuments, taskFormPendingDocuments]); + + const addEventToTaskForm = useCallback((event: CalendarEvent) => { + const isExisting = taskFormEvents.some((link) => link.event_id === event.id); + const isPending = taskFormPendingEvents.some((item) => item.id === event.id); + if (isExisting || isPending) return; + setTaskFormPendingEvents((current) => [...current, event]); + setTaskFormLinksOpen(true); + setLinkedItemModalOpen(false); + }, [taskFormEvents, taskFormPendingEvents]); + + const addExternalLinkToTaskForm = useCallback(() => { + const url = normalizeExternalUrl(externalLinkUrl); + if (!url) return; + const duplicate = ( + taskFormExternalLinks.some((link) => link.url === url) || + taskFormPendingExternalLinks.some((link) => link.url === url) + ); + if (duplicate) { + setError("Эта ссылка уже добавлена к задаче"); + return; + } + + setTaskFormPendingExternalLinks((current) => [ + ...current, + { + key: `${Date.now()}-${current.length}`, + url, + title: externalLinkTitle.trim(), + }, + ]); + setTaskFormLinksOpen(true); + setLinkedItemModalOpen(false); + setExternalLinkUrl(""); + setExternalLinkTitle(""); + }, [ + externalLinkTitle, + externalLinkUrl, + taskFormExternalLinks, + taskFormPendingExternalLinks, + ]); + + const deleteExternalLinkFromViewedTask = useCallback(async (linkId: number) => { + if (!viewTask || saving) return; + if (!window.confirm("Удалить ссылку из задачи?")) return; + + setSaving(true); + setError(null); + try { + await apiClient.deleteTaskExternalLink(viewTask.id, linkId); + await refreshViewedTaskLinks(); + } catch (linkError) { + setError(getTaskError(linkError, "Не удалось удалить ссылку")); + } finally { + setSaving(false); + } + }, [refreshViewedTaskLinks, saving, viewTask]); + const addViewedTaskComment = useCallback(async () => { if (!board || !viewTask || saving || !commentDraft.trim()) return; @@ -2577,15 +3720,50 @@ function TasksPageContent() { setError(null); try { const created = await apiClient.addTaskComment(viewTask.id, commentDraft.trim()); - setTaskComments((current) => [...current, created]); + setTaskComments((current) => [ + ...current.filter((comment) => comment.id !== created.id), + created, + ]); setCommentDraft(""); await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); } catch (commentError) { setError(getTaskError(commentError, "Не удалось добавить комментарий")); } finally { setSaving(false); } - }, [board, commentDraft, loadBoard, saving, viewTask]); + }, [activityOpen, board, commentDraft, loadBoard, loadTaskActivity, saving, viewTask]); + + const beginEditingViewedTaskComment = useCallback((comment: TaskComment) => { + setEditingCommentId(comment.id); + setEditingCommentDraft(comment.text); + }, []); + + const cancelEditingViewedTaskComment = useCallback(() => { + setEditingCommentId(null); + setEditingCommentDraft(""); + }, []); + + const updateViewedTaskComment = useCallback(async () => { + const text = editingCommentDraft.trim(); + if (!viewTask || !editingCommentId || !text || saving) return; + + setSaving(true); + setError(null); + try { + const updated = await apiClient.updateTaskComment(viewTask.id, editingCommentId, text); + setTaskComments((current) => current.map((comment) => ( + comment.id === updated.id ? updated : comment + ))); + setEditingCommentId(null); + setEditingCommentDraft(""); + if (activityOpen) await loadTaskActivity(viewTask.id); + } catch (commentError) { + setError(getTaskError(commentError, "Не удалось изменить комментарий")); + } finally { + setSaving(false); + } + }, [activityOpen, editingCommentDraft, editingCommentId, loadTaskActivity, saving, viewTask]); const deleteViewedTaskComment = useCallback(async (commentId: number) => { if (!board || !viewTask || saving) return; @@ -2595,13 +3773,18 @@ function TasksPageContent() { try { await apiClient.deleteTaskComment(viewTask.id, commentId); setTaskComments((current) => current.filter((comment) => comment.id !== commentId)); + if (editingCommentId === commentId) { + setEditingCommentId(null); + setEditingCommentDraft(""); + } await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); } catch (commentError) { setError(getTaskError(commentError, "Не удалось удалить комментарий")); } finally { setSaving(false); } - }, [board, loadBoard, saving, viewTask]); + }, [activityOpen, board, editingCommentId, loadBoard, loadTaskActivity, saving, viewTask]); const unlinkPostFromViewedTask = useCallback(async (linkId: number) => { if (!board || !viewTask || saving) return; @@ -2794,6 +3977,70 @@ function TasksPageContent() { } }, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]); + const claimTask = useCallback(async (task: TaskCard) => { + if ( + !board || + task.assignee || + task.completed_at || + claimingTaskId !== null + ) { + return; + } + + setClaimingTaskId(task.id); + setCardMenuTaskId(null); + setError(null); + try { + await apiClient.claimTask(task.id); + await loadBoard(board.id); + if (activityOpen && viewTaskId === task.id) { + await loadTaskActivity(task.id); + } + } catch (claimError) { + setError(getTaskError(claimError, "Не удалось взять задачу в работу")); + await loadBoard(board.id); + } finally { + setClaimingTaskId(null); + } + }, [activityOpen, board, claimingTaskId, loadBoard, loadTaskActivity, viewTaskId]); + + const completeTask = useCallback(async (task: TaskCard) => { + if ( + !board || + !user?.id || + task.assignee?.id !== user.id || + task.completed_at || + completingTaskId !== null + ) { + return; + } + + setCompletingTaskId(task.id); + setCardMenuTaskId(null); + setViewColumnMenuOpen(false); + setError(null); + try { + await apiClient.completeTask(task.id); + await loadBoard(board.id); + if (activityOpen && viewTaskId === task.id) { + await loadTaskActivity(task.id); + } + } catch (completeError) { + setError(getTaskError(completeError, "Не удалось завершить задачу")); + await loadBoard(board.id); + } finally { + setCompletingTaskId(null); + } + }, [ + activityOpen, + board, + completingTaskId, + loadBoard, + loadTaskActivity, + user?.id, + viewTaskId, + ]); + const handleDragStart = useCallback((event: DragStartEvent) => { const taskId = Number(String(event.active.id).replace("task-", "")); setActiveTaskId(Number.isFinite(taskId) ? taskId : null); @@ -2853,7 +4100,7 @@ function TasksPageContent() { +
+
+ + setBoardMemberSearch(event.target.value)} + className="app-input w-full rounded-xl py-2 pl-8 pr-3 text-xs" + placeholder="Поиск сотрудников" + aria-label="Поиск сотрудников" + /> +
- {employees.length > 0 ? ( - employees.map((employee) => ( - - )) + {filteredBoardMembers.length > 0 ? ( + filteredBoardMembers.map((employee) => { + const employeeName = displayUserName(employee); + return ( + + ); + }) ) : ( -

Нет сотрудников

+

+ Сотрудники не найдены +

)}
@@ -3240,9 +4534,22 @@ function TasksPageContent() { {boardForm.department_ids.length}
+
+ + setBoardDepartmentSearch(event.target.value)} + className="app-input w-full rounded-xl py-2 pl-8 pr-3 text-xs" + placeholder="Поиск отделов" + aria-label="Поиск отделов" + /> +
- {departments.length > 0 ? ( - departments.map((department) => ( + {filteredBoardDepartments.length > 0 ? ( + filteredBoardDepartments.map((department) => (
@@ -3357,6 +4666,38 @@ function TasksPageContent() {
+ {!viewTask.assignee && !viewTask.completed_at ? ( + + ) : null} + {viewTask.assignee?.id === user?.id && !viewTask.completed_at ? ( + + ) : null} {viewTaskColumn ? (
+ +
+ + {checklistOpen ? ( +
+ {taskChecklistLoading && !taskChecklistLoaded ? ( +
+ +
+ ) : taskChecklist.length > 0 ? ( +
+ {taskChecklist.map((item) => ( +
+ void toggleViewedTaskChecklistItem(item)} + disabled={taskChecklistSaving} + className="h-4 w-4 shrink-0 accent-sky-500" + aria-label={item.is_completed ? "Вернуть пункт в работу" : "Отметить пункт выполненным"} + /> + + {item.title} + + +
+ ))} +
+ ) : ( +
+

Пунктов пока нет

+
+ )} + +
+ setTaskChecklistDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + void addViewedTaskChecklistItem(); + }} + disabled={taskChecklistSaving} + className="app-input min-w-0 flex-1 rounded-xl px-3 py-2 text-sm" + placeholder="Новый пункт" + /> + +
+
+ ) : null} +
+ {viewTask.labels && viewTask.labels.length > 0 ? (
@@ -3496,6 +4948,122 @@ function TasksPageContent() {
) : null} +
+
+ + { + const files = Array.from(event.currentTarget.files || []); + event.currentTarget.value = ""; + setAttachmentsOpen(true); + void uploadViewedTaskAttachments(files); + }} + /> + + +
+ + {attachmentsOpen ? ( +
+ {taskAttachmentsLoading ? ( +
+ +
+ ) : taskAttachments.length > 0 ? ( +
+ {taskAttachments.map((attachment) => ( +
+ + +
+ ))} +
+ ) : ( +
+

Файлов пока нет

+
+ )} +
+ ) : null} +
+
+ ) : null} + {canDeleteComment ? ( + deleteViewedTaskComment(comment.id)} + /> + ) : null} +
) : null}
-

- {comment.text} -

+ {editingCommentId === comment.id ? ( +
+