feat: expand documents tasks and procurement workflows
This commit is contained in:
parent
b9598c2731
commit
df2377431d
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<comment_id>\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(
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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<attachment_id>\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"])
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<attachment_id>\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<attachment_id>\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<item_id>\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<link_id>\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<comment_id>\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",
|
||||
|
||||
@ -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]:
|
||||
"""
|
||||
|
||||
@ -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
|
||||
|
||||
51
backend/documents/audience.py
Normal file
51
backend/documents/audience.py
Normal file
@ -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()
|
||||
@ -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="Сотрудники для ознакомления",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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(
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
Уведомляет загрузившего о том, что все ознакомились с документом.
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# ЛОГИРОВАНИЕ
|
||||
|
||||
@ -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 (
|
||||
|
||||
21
backend/procurement/activity.py
Normal file
21
backend/procurement/activity.py
Normal file
@ -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 {},
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
),
|
||||
]
|
||||
@ -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):
|
||||
"""Правило получения согласующего для этапа с заданным приоритетом."""
|
||||
|
||||
|
||||
98
backend/procurement/signals.py
Normal file
98
backend/procurement/signals.py
Normal file
@ -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
|
||||
)
|
||||
@ -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}"
|
||||
|
||||
@ -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='Вложение'),
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
],
|
||||
)
|
||||
|
||||
@ -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;
|
||||
|
||||
// Проверяем размер изображения
|
||||
|
||||
@ -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()
|
||||
)
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -8,3 +8,4 @@ class TasksConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
import tasks.notifications.signals # noqa: F401
|
||||
import tasks.signals # noqa: F401
|
||||
|
||||
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
43
backend/tasks/migrations/0016_taskboard_access_scope.py
Normal file
43
backend/tasks/migrations/0016_taskboard_access_scope.py
Normal file
@ -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,
|
||||
),
|
||||
]
|
||||
59
backend/tasks/migrations/0017_task_comment_activity.py
Normal file
59
backend/tasks/migrations/0017_task_comment_activity.py
Normal file
@ -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="Тип объекта",
|
||||
),
|
||||
),
|
||||
]
|
||||
36
backend/tasks/migrations/0018_task_claim_activity.py
Normal file
36
backend/tasks/migrations/0018_task_claim_activity.py
Normal file
@ -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="Действие",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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}"
|
||||
|
||||
@ -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(
|
||||
|
||||
15
backend/tasks/signals.py
Normal file
15
backend/tasks/signals.py
Normal file
@ -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)
|
||||
@ -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. Производительность/фильтры --------------------
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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})
|
||||
|
||||
34
backend/tests/employees/test_ldap_avatar_processing.py
Normal file
34
backend/tests/employees/test_ldap_avatar_processing.py
Normal file
@ -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("Ожидалась ошибка для нулевого лимита")
|
||||
@ -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() {
|
||||
|
||||
<div className="flex shrink-0 items-start gap-2">
|
||||
<div className="text-right">
|
||||
{doc.acknowledgement_required ? (
|
||||
{acknowledgementRequiredForUser ? (
|
||||
doc.is_acknowledged ? (
|
||||
<span className="app-feedback-success inline-flex rounded-full px-2.5 py-1 text-xs font-medium">
|
||||
Ознакомлен
|
||||
@ -1538,7 +1448,7 @@ function DocumentsPageContent() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{doc.acknowledgement_required && !doc.is_acknowledged && (
|
||||
{acknowledgementRequiredForUser && !doc.is_acknowledged && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAcknowledgeDocument(doc)}
|
||||
@ -1546,7 +1456,7 @@ function DocumentsPageContent() {
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
{isAcknowledging ? <Loader2 size={14} className="animate-spin" /> : <CheckCircle size={14} />}
|
||||
{isAcknowledging ? "Подтверждение..." : "Ознакомиться"}
|
||||
{isAcknowledging ? "Подтверждение..." : "Ознакомился(лась)"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@ -1641,7 +1551,7 @@ function DocumentsPageContent() {
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center justify-end gap-1.5">
|
||||
{doc.acknowledgement_required && !doc.is_acknowledged ? (
|
||||
{acknowledgementRequiredForUser && !doc.is_acknowledged ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAcknowledgeDocument(doc)}
|
||||
@ -1654,7 +1564,7 @@ function DocumentsPageContent() {
|
||||
) : (
|
||||
<CheckCircle size={14} />
|
||||
)}
|
||||
<span>{isAcknowledging ? "Подтверждение..." : "Ознакомиться"}</span>
|
||||
<span>{isAcknowledging ? "Подтверждение..." : "Ознакомился(лась)"}</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@ -1732,7 +1642,7 @@ function DocumentsPageContent() {
|
||||
isOpen={showUploadForm}
|
||||
onClose={() => setShowUploadForm(false)}
|
||||
title="Загрузить документ"
|
||||
size="lg"
|
||||
size="xl"
|
||||
>
|
||||
<DocumentUploadForm
|
||||
currentFolderId={selectedFolderId}
|
||||
|
||||
@ -19,6 +19,8 @@
|
||||
--danger-soft: rgba(239, 68, 68, 0.12);
|
||||
--danger-foreground: #dc2626;
|
||||
--warning-soft: rgba(245, 158, 11, 0.15);
|
||||
--regulation-border: #a9822f;
|
||||
--regulation-soft: rgba(169, 130, 47, 0.1);
|
||||
--app-shell-gradient: radial-gradient(circle at top, rgba(125, 211, 252, 0.12), transparent 32%), linear-gradient(180deg, #f5f9ff 0%, #eef3fb 46%, #f4f7fb 100%);
|
||||
--header-surface: rgba(255, 255, 255, 0.84);
|
||||
--shadow-card: 0 12px 30px -22px rgba(15, 23, 42, 0.16), 0 3px 10px -8px rgba(15, 23, 42, 0.08);
|
||||
@ -51,6 +53,8 @@ html[data-bs-theme="dark"] {
|
||||
--danger-soft: rgba(239, 68, 68, 0.16);
|
||||
--danger-foreground: #f87171;
|
||||
--warning-soft: rgba(245, 158, 11, 0.18);
|
||||
--regulation-border: #d0ad58;
|
||||
--regulation-soft: rgba(208, 173, 88, 0.12);
|
||||
--app-shell-gradient: radial-gradient(circle at top, rgba(30, 167, 255, 0.08), transparent 24%), linear-gradient(180deg, #10161d 0%, #111922 38%, #0b1016 100%);
|
||||
--header-surface: rgba(14, 20, 28, 0.88);
|
||||
--shadow-card: 0 20px 48px -30px rgba(2, 6, 23, 0.72);
|
||||
|
||||
@ -199,12 +199,6 @@ export default function ChatSettingsPage() {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Проверка размера (макс 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert("Файл слишком большой. Максимальный размер: 5MB");
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверка типа
|
||||
if (!file.type.startsWith("image/")) {
|
||||
alert("Можно загружать только изображения");
|
||||
|
||||
@ -389,10 +389,6 @@ export default function MessagesPage() {
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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;
|
||||
|
||||
@ -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<Post[]>([]);
|
||||
const [regulations, setRegulations] = useState<Document[]>([]);
|
||||
const [selectedRegulation, setSelectedRegulation] = useState<Document | null>(null);
|
||||
const [selectedRegulationInitialTab, setSelectedRegulationInitialTab] = useState<"comments" | undefined>();
|
||||
const [metadataRegulation, setMetadataRegulation] = useState<Document | null>(null);
|
||||
const [taskLinkRegulation, setTaskLinkRegulation] = useState<Document | null>(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<number | null>(null);
|
||||
const [acknowledgementsReport, setAcknowledgementsReport] = useState<{
|
||||
documentId: number;
|
||||
documentTitle: string;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [likeBusyId, setLikeBusyId] = useState<number | null>(null);
|
||||
const [sourceFilter, setSourceFilter] = useState<PostSourceFilter>("all");
|
||||
const [regulationsOnly, setRegulationsOnly] = useState(false);
|
||||
|
||||
const [commentsOpen, setCommentsOpen] = useState(false);
|
||||
const [activePost, setActivePost] = useState<Post | null>(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<string>("");
|
||||
const [createTitle, setCreateTitle] = useState("");
|
||||
@ -80,6 +136,7 @@ function HomePageContent() {
|
||||
const [postMenuOpenId, setPostMenuOpenId] = useState<number | null>(null);
|
||||
const [taskLinkPost, setTaskLinkPost] = useState<Post | null>(null);
|
||||
const postMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const createMenuRef = useRef<HTMLDivElement | null>(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<Document>((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<number, { id: number; name: string; total: number }>();
|
||||
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<number, number>();
|
||||
let companyUnread = 0;
|
||||
let regulationsUnread = 0;
|
||||
let companyRegulationsUnread = 0;
|
||||
const departmentRegulationsUnread = new Map<number, number>();
|
||||
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<string, unknown>
|
||||
: 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<FeedEntry[]>(() => {
|
||||
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() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{postSourceOptions.map(({ key, label, total, unread }) => {
|
||||
const active = sourceFilter === key;
|
||||
const active = key === "regulations" ? regulationsOnly : sourceFilter === key;
|
||||
|
||||
return (
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setSourceFilter(key)}
|
||||
aria-pressed={active}
|
||||
onClick={() => {
|
||||
if (key === "regulations") {
|
||||
setRegulationsOnly((current) => !current);
|
||||
} else {
|
||||
setSourceFilter(key);
|
||||
}
|
||||
}}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
active ? "app-pill-active" : "app-pill"
|
||||
}`}
|
||||
@ -681,11 +919,11 @@ function HomePageContent() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
{posts.length === 0 && regulations.length === 0 ? (
|
||||
<div className="app-surface-muted rounded-2xl p-8 text-center">
|
||||
<p className="app-text-muted text-sm">Пока нет постов в ленте</p>
|
||||
<p className="app-text-muted text-sm">Пока нет публикаций в ленте</p>
|
||||
</div>
|
||||
) : filteredPosts.length === 0 ? (
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<div className="app-surface-muted rounded-2xl p-8 text-center">
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">
|
||||
Публикации не найдены
|
||||
@ -695,7 +933,31 @@ function HomePageContent() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredPosts.map((post) => {
|
||||
filteredEntries.map((entry) => {
|
||||
if (entry.kind === "regulation") {
|
||||
return (
|
||||
<FeedRegulationCard
|
||||
key={`regulation-${entry.document.id}`}
|
||||
currentUserId={user?.id}
|
||||
document={entry.document}
|
||||
isAcknowledging={acknowledgingRegulationId === entry.document.id}
|
||||
onAcknowledge={(document) => 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 (
|
||||
<FeedPostCard
|
||||
key={post.id}
|
||||
key={`post-${post.id}`}
|
||||
authorHref={post.author ? userProfileLink(post.author, user?.id) : null}
|
||||
post={post}
|
||||
authorSubtitle={
|
||||
@ -854,20 +1116,65 @@ function HomePageContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canCreatePost && !createPostOpen ? (
|
||||
<div className="pointer-events-none fixed bottom-[calc(env(safe-area-inset-bottom)+5.5rem)] right-4 z-30 lg:bottom-6 lg:right-[max(2rem,calc((100vw-72rem)/2+21.5rem))]">
|
||||
{user && !createPostOpen && !createRegulationOpen ? (
|
||||
<div
|
||||
ref={createMenuRef}
|
||||
className="pointer-events-none fixed bottom-[calc(env(safe-area-inset-bottom)+5.5rem)] right-4 z-30 flex flex-col items-end gap-2 lg:bottom-6 lg:right-[max(2rem,calc((100vw-72rem)/2+21.5rem))]"
|
||||
>
|
||||
{createMenuOpen ? (
|
||||
<div className="flex flex-col items-end gap-2" role="menu" aria-label="Создать в ленте">
|
||||
{canCreatePost ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={openCreatePostModal}
|
||||
className="app-action-primary pointer-events-auto inline-flex h-10 items-center gap-2 rounded-full px-4 text-sm font-medium shadow-[var(--shadow-card)]"
|
||||
>
|
||||
<Newspaper size={17} />
|
||||
Новость
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={openCreateRegulationModal}
|
||||
className="app-action-primary pointer-events-auto inline-flex h-10 items-center gap-2 rounded-full px-4 text-sm font-medium shadow-[var(--shadow-card)]"
|
||||
>
|
||||
<ScrollText size={17} />
|
||||
Регламент
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreatePostModal}
|
||||
onClick={() => setCreateMenuOpen((current) => !current)}
|
||||
className="app-action-primary pointer-events-auto inline-flex h-12 w-12 items-center justify-center rounded-full p-0 leading-none shadow-[var(--shadow-card)] transition active:scale-[0.98]"
|
||||
title="Создать публикацию"
|
||||
aria-label="Создать публикацию"
|
||||
title={createMenuOpen ? "Закрыть меню создания" : "Создать"}
|
||||
aria-label={createMenuOpen ? "Закрыть меню создания" : "Открыть меню создания"}
|
||||
aria-expanded={createMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Plus size={22} />
|
||||
<Plus size={22} className={`transition-transform duration-200 ${createMenuOpen ? "rotate-45" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
isOpen={createRegulationOpen}
|
||||
onClose={() => setCreateRegulationOpen(false)}
|
||||
title="Создать регламент"
|
||||
size="xl"
|
||||
>
|
||||
<DocumentUploadForm
|
||||
defaultIsRegulation
|
||||
onCancel={() => setCreateRegulationOpen(false)}
|
||||
onSuccess={() => {
|
||||
setCreateRegulationOpen(false);
|
||||
void refreshRegulations();
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={createPostOpen}
|
||||
onClose={() => setCreatePostOpen(false)}
|
||||
@ -979,6 +1286,85 @@ function HomePageContent() {
|
||||
post={activePost}
|
||||
/>
|
||||
|
||||
<DocumentDetailModal
|
||||
key={selectedRegulation ? `${selectedRegulation.id}-${selectedRegulationInitialTab || "default"}` : "regulation-detail"}
|
||||
document={selectedRegulation}
|
||||
isOpen={Boolean(selectedRegulation)}
|
||||
initialTab={selectedRegulationInitialTab}
|
||||
onClose={() => {
|
||||
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 ? (
|
||||
<DocumentMetadataEditor
|
||||
isOpen
|
||||
document={metadataRegulation}
|
||||
onClose={() => setMetadataRegulation(null)}
|
||||
onUpdate={() => {
|
||||
void refreshRegulations();
|
||||
if (selectedRegulation?.id === metadataRegulation.id) {
|
||||
void apiClient.getDocument(metadataRegulation.id).then(setSelectedRegulation);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{taskLinkRegulation ? (
|
||||
<DocumentTaskLinks
|
||||
document={taskLinkRegulation}
|
||||
variant="dialog"
|
||||
open
|
||||
onClose={() => setTaskLinkRegulation(null)}
|
||||
onLinked={() => void refreshRegulations()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{regulationPreviewFile ? (
|
||||
<DocumentPreview
|
||||
fileUrl={regulationPreviewFile.url}
|
||||
fileName={regulationPreviewFile.name}
|
||||
onClose={() => setRegulationPreviewFile(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{regulationPdfFile ? (
|
||||
<EnhancedPDFViewer
|
||||
fileUrl={regulationPdfFile.url}
|
||||
fileName={regulationPdfFile.name}
|
||||
onClose={() => setRegulationPdfFile(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{acknowledgementsReport ? (
|
||||
<DocumentAcknowledgementsReport
|
||||
documentId={acknowledgementsReport.documentId}
|
||||
documentTitle={acknowledgementsReport.documentTitle}
|
||||
onClose={() => setAcknowledgementsReport(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{taskLinkPost ? (
|
||||
<RelatedTaskLinks
|
||||
key={`post-task-link-${taskLinkPost.id}`}
|
||||
|
||||
@ -34,6 +34,7 @@ import {
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Package,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
@ -455,6 +456,9 @@ export default function ProcurementPage() {
|
||||
handleDeleteComment,
|
||||
handleAddItemComment,
|
||||
handleDeleteItemComment,
|
||||
handleDownloadItemAttachment,
|
||||
handleUploadItemAttachments,
|
||||
handleDeleteItemAttachment,
|
||||
markRequestNotificationsRead,
|
||||
toggleItemComments,
|
||||
} = useProcurementPage(user);
|
||||
@ -1518,6 +1522,9 @@ export default function ProcurementPage() {
|
||||
onItemCommentDraftChange={(itemId, value) => setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))}
|
||||
onAddItemComment={handleAddItemComment}
|
||||
onDeleteItemComment={handleDeleteItemComment}
|
||||
onDownloadItemAttachment={handleDownloadItemAttachment}
|
||||
onUploadItemAttachments={handleUploadItemAttachments}
|
||||
onDeleteItemAttachment={handleDeleteItemAttachment}
|
||||
footer={(
|
||||
<ProcurementRequestActionButtons
|
||||
request={resolvedDetail}
|
||||
@ -1695,6 +1702,9 @@ export default function ProcurementPage() {
|
||||
onItemCommentDraftChange={(itemId, value) => setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))}
|
||||
onAddItemComment={handleAddItemComment}
|
||||
onDeleteItemComment={handleDeleteItemComment}
|
||||
onDownloadItemAttachment={handleDownloadItemAttachment}
|
||||
onUploadItemAttachments={handleUploadItemAttachments}
|
||||
onDeleteItemAttachment={handleDeleteItemAttachment}
|
||||
/>
|
||||
|
||||
<div className="app-surface rounded-xl p-4">
|
||||
@ -2227,6 +2237,77 @@ export default function ProcurementPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="app-text-muted text-xs font-medium">Файлы</span>
|
||||
<label className="app-link-accent inline-flex cursor-pointer items-center gap-1 text-xs font-medium">
|
||||
<Paperclip size={12} /> Прикрепить
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
updateItemRow(idx, { pending_files: [...it.pending_files, ...files] });
|
||||
}
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{it.attachments.length > 0 || it.pending_files.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{it.attachments.map((attachment) => (
|
||||
<span
|
||||
key={`attachment-${attachment.id}`}
|
||||
className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex min-w-0 items-center gap-1"
|
||||
onClick={() => void handleDownloadItemAttachment(it.id!, attachment)}
|
||||
>
|
||||
<Paperclip size={11} className="shrink-0" />
|
||||
<span className="max-w-48 truncate">{attachment.file_name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Удалить файл"
|
||||
onClick={() => updateItemRow(idx, {
|
||||
attachments: it.attachments.filter((current) => current.id !== attachment.id),
|
||||
removed_attachment_ids: [...it.removed_attachment_ids, attachment.id],
|
||||
})}
|
||||
className="app-action-ghost inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{it.pending_files.map((file, fileIndex) => (
|
||||
<span
|
||||
key={`pending-${file.name}-${file.lastModified}-${fileIndex}`}
|
||||
className="app-selected inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]"
|
||||
>
|
||||
<Paperclip size={11} className="shrink-0" />
|
||||
<span className="max-w-48 truncate">{file.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
title="Убрать файл"
|
||||
onClick={() => updateItemRow(idx, {
|
||||
pending_files: it.pending_files.filter((_, currentIndex) => currentIndex !== fileIndex),
|
||||
})}
|
||||
className="app-action-ghost inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="app-text-muted text-[11px]">Файлы не прикреплены</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -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('Выберите изображение');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,7 @@ import { MobileLeftDrawer, MobileCalendarDrawer } from "@/components/layout/Mobi
|
||||
import { usePwa } from "@/contexts/PwaContext";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { NAV_NOTIFICATION_CATEGORIES } from "@/lib/navigation-notifications";
|
||||
import type { GuestVisit, GuestVisitComment } from "@/types/api";
|
||||
|
||||
type AppShellProps = {
|
||||
@ -50,17 +51,17 @@ type PaginatedResponse<T> = {
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "Лента", icon: HomeIcon, category: "Новости" },
|
||||
{ href: "/messages", label: "Сообщения", icon: MessageSquare, category: "Сообщения" },
|
||||
{ href: "/", label: "Лента", icon: HomeIcon, category: NAV_NOTIFICATION_CATEGORIES.feed },
|
||||
{ href: "/messages", label: "Сообщения", icon: MessageSquare, category: NAV_NOTIFICATION_CATEGORIES.messages },
|
||||
{ href: "/employees", label: "Сотрудники", icon: Users },
|
||||
{ href: "/departments", label: "Отделы", icon: Building2 },
|
||||
{ href: "/attendance", label: "Посещаемость", icon: CalendarCheck },
|
||||
{ href: "/requests", label: "Заявления", icon: FileSignature, category: "Заявки" },
|
||||
{ href: "/guests", label: "Гости", icon: UserRoundPlus, category: "Гости" },
|
||||
{ href: "/requests", label: "Заявления", icon: FileSignature, category: NAV_NOTIFICATION_CATEGORIES.requests },
|
||||
{ href: "/guests", label: "Гости", icon: UserRoundPlus, category: NAV_NOTIFICATION_CATEGORIES.guests },
|
||||
// { href: "/equipment", label: "Оборудование", icon: Monitor },
|
||||
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: "Закупки", autoReadOnNavigate: false },
|
||||
{ href: "/tasks", label: "Доска", icon: Kanban, category: "Доска" },
|
||||
{ href: "/documents", label: "Документы", icon: FileText, category: "Документы" },
|
||||
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: NAV_NOTIFICATION_CATEGORIES.procurement, autoReadOnNavigate: false },
|
||||
{ href: "/tasks", label: "Доска", icon: Kanban, category: NAV_NOTIFICATION_CATEGORIES.tasks },
|
||||
{ href: "/documents", label: "Документы", icon: FileText, category: NAV_NOTIFICATION_CATEGORIES.documents },
|
||||
// { href: "/finances", label: "Финансы", icon: Wallet },
|
||||
];
|
||||
|
||||
|
||||
@ -36,11 +36,18 @@ export function DocumentAcknowledgement({
|
||||
|
||||
const acknowledgements = document.acknowledgements || [];
|
||||
const isAcknowledged = document.is_acknowledged || false;
|
||||
const acknowledgementRequiredForUser =
|
||||
document.acknowledgement_required_for_user ?? document.acknowledgement_required;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Acknowledge Button/Status */}
|
||||
{!isAcknowledged ? (
|
||||
{!acknowledgementRequiredForUser ? (
|
||||
<div className="app-surface-muted flex items-center gap-2 rounded-lg px-3 py-2 text-sm">
|
||||
<CheckCircle size={16} className="app-text-muted shrink-0" />
|
||||
<span>Для вас подтверждение ознакомления не требуется</span>
|
||||
</div>
|
||||
) : !isAcknowledged ? (
|
||||
<div className="app-feedback-warning rounded-xl p-4">
|
||||
<div className="mb-3 flex items-start gap-3">
|
||||
<CheckCircle size={20} className="mt-0.5 shrink-0" />
|
||||
@ -59,7 +66,7 @@ export function DocumentAcknowledgement({
|
||||
disabled={isSubmitting}
|
||||
className="app-action-primary w-full rounded-lg px-4 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Подтверждение..." : "Подтвердить прочтение"}
|
||||
{isSubmitting ? "Подтверждение..." : "Ознакомился(лась)"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
243
frontend/src/components/documents/DocumentAudienceSelector.tsx
Normal file
243
frontend/src/components/documents/DocumentAudienceSelector.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { RequestAvatar } from "@/components/requests/RequestAvatar";
|
||||
import type { Department, User } from "@/types/api";
|
||||
|
||||
export type DocumentAudienceMode = "all" | "none" | "restricted";
|
||||
|
||||
type DocumentAudienceSelectorProps = {
|
||||
kind: "access" | "acknowledgement";
|
||||
mode: DocumentAudienceMode;
|
||||
onModeChange: (mode: DocumentAudienceMode) => void;
|
||||
employees: User[];
|
||||
departments: Department[];
|
||||
selectedEmployeeIds: number[];
|
||||
selectedDepartmentIds: number[];
|
||||
onSelectedEmployeeIdsChange: (ids: number[]) => void;
|
||||
onSelectedDepartmentIdsChange: (ids: number[]) => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function employeeName(employee: User) {
|
||||
return [employee.last_name, employee.first_name, employee.patronymic]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim() || employee.email || `Сотрудник #${employee.id}`;
|
||||
}
|
||||
|
||||
function employeeInitials(employee: User) {
|
||||
return `${employee.last_name?.[0] || ""}${employee.first_name?.[0] || ""}`.toUpperCase() || "С";
|
||||
}
|
||||
|
||||
function normalizeSearch(value: string) {
|
||||
return value.trim().toLocaleLowerCase("ru");
|
||||
}
|
||||
|
||||
export function DocumentAudienceSelector({
|
||||
kind,
|
||||
mode,
|
||||
onModeChange,
|
||||
employees,
|
||||
departments,
|
||||
selectedEmployeeIds,
|
||||
selectedDepartmentIds,
|
||||
onSelectedEmployeeIdsChange,
|
||||
onSelectedDepartmentIdsChange,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
}: DocumentAudienceSelectorProps) {
|
||||
const [employeeSearch, setEmployeeSearch] = useState("");
|
||||
const [departmentSearch, setDepartmentSearch] = useState("");
|
||||
const isAcknowledgement = kind === "acknowledgement";
|
||||
|
||||
const options: Array<{ value: DocumentAudienceMode; label: string }> = isAcknowledgement
|
||||
? [
|
||||
{ value: "all", label: "Для всех с доступом" },
|
||||
{ value: "none", label: "Не требуется" },
|
||||
{ value: "restricted", label: "Выборочно" },
|
||||
]
|
||||
: [
|
||||
{ value: "all", label: "Для всех" },
|
||||
{ value: "restricted", label: "Выборочно" },
|
||||
];
|
||||
|
||||
const filteredEmployees = useMemo(() => {
|
||||
const query = normalizeSearch(employeeSearch);
|
||||
if (!query) return employees;
|
||||
return employees.filter((employee) => (
|
||||
`${employeeName(employee)} ${employee.email || ""}`.toLocaleLowerCase("ru").includes(query)
|
||||
));
|
||||
}, [employeeSearch, employees]);
|
||||
|
||||
const filteredDepartments = useMemo(() => {
|
||||
const query = normalizeSearch(departmentSearch);
|
||||
if (!query) return departments;
|
||||
return departments.filter((department) => department.name.toLocaleLowerCase("ru").includes(query));
|
||||
}, [departmentSearch, departments]);
|
||||
|
||||
const toggleEmployee = (employeeId: number) => {
|
||||
onSelectedEmployeeIdsChange(
|
||||
selectedEmployeeIds.includes(employeeId)
|
||||
? selectedEmployeeIds.filter((id) => id !== employeeId)
|
||||
: [...selectedEmployeeIds, employeeId],
|
||||
);
|
||||
};
|
||||
|
||||
const toggleDepartment = (departmentId: number) => {
|
||||
onSelectedDepartmentIdsChange(
|
||||
selectedDepartmentIds.includes(departmentId)
|
||||
? selectedDepartmentIds.filter((id) => id !== departmentId)
|
||||
: [...selectedDepartmentIds, departmentId],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground)]">
|
||||
{isAcknowledgement ? "Требование ознакомления" : "Доступ к документу"}
|
||||
</h3>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
{isAcknowledgement
|
||||
? "Выберите сотрудников, которые должны подтвердить ознакомление."
|
||||
: "Выберите сотрудников, которые смогут открыть документ."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`grid gap-2 ${options.length === 3 ? "sm:grid-cols-3" : "sm:grid-cols-2"}`}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onModeChange(option.value)}
|
||||
disabled={disabled}
|
||||
aria-pressed={mode === option.value}
|
||||
className={`rounded-xl border px-3 py-2 text-left text-sm font-medium transition disabled:opacity-50 ${
|
||||
mode === option.value
|
||||
? "app-selected border-[var(--accent-primary)]"
|
||||
: "border-[var(--border-subtle)] text-[var(--muted-foreground)] hover:border-[var(--border-strong)]"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === "restricted" ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="app-text-muted text-xs font-medium">Сотрудники</span>
|
||||
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">
|
||||
{selectedEmployeeIds.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative mb-2">
|
||||
<Search
|
||||
size={14}
|
||||
className="app-text-muted pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
<input
|
||||
value={employeeSearch}
|
||||
onChange={(event) => setEmployeeSearch(event.target.value)}
|
||||
disabled={disabled}
|
||||
className="app-input w-full rounded-xl py-2 pl-8 pr-3 text-xs"
|
||||
placeholder="Поиск сотрудников"
|
||||
aria-label={`${isAcknowledgement ? "Ознакомление" : "Доступ"}: поиск сотрудников`}
|
||||
/>
|
||||
</div>
|
||||
<div className="tasks-column-scroll app-surface-muted max-h-52 space-y-1 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-2">
|
||||
{loading ? (
|
||||
<p className="app-text-muted px-2 py-4 text-center text-xs">Загрузка сотрудников...</p>
|
||||
) : filteredEmployees.length > 0 ? (
|
||||
filteredEmployees.map((employee) => {
|
||||
const name = employeeName(employee);
|
||||
return (
|
||||
<label
|
||||
key={employee.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition hover:bg-[var(--surface-elevated)]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEmployeeIds.includes(employee.id)}
|
||||
onChange={() => toggleEmployee(employee.id)}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
<RequestAvatar
|
||||
alt={name}
|
||||
fallback={employeeInitials(employee)}
|
||||
src={employee.avatar}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium text-[var(--foreground)]">{name}</span>
|
||||
{employee.email ? (
|
||||
<span className="app-text-muted block truncate text-[10px]">{employee.email}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="app-text-muted px-2 py-4 text-center text-xs">Сотрудники не найдены</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="app-text-muted text-xs font-medium">Отделы</span>
|
||||
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">
|
||||
{selectedDepartmentIds.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative mb-2">
|
||||
<Search
|
||||
size={14}
|
||||
className="app-text-muted pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
<input
|
||||
value={departmentSearch}
|
||||
onChange={(event) => setDepartmentSearch(event.target.value)}
|
||||
disabled={disabled}
|
||||
className="app-input w-full rounded-xl py-2 pl-8 pr-3 text-xs"
|
||||
placeholder="Поиск отделов"
|
||||
aria-label={`${isAcknowledgement ? "Ознакомление" : "Доступ"}: поиск отделов`}
|
||||
/>
|
||||
</div>
|
||||
<div className="tasks-column-scroll app-surface-muted max-h-52 space-y-1 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-2">
|
||||
{loading ? (
|
||||
<p className="app-text-muted px-2 py-4 text-center text-xs">Загрузка отделов...</p>
|
||||
) : filteredDepartments.length > 0 ? (
|
||||
filteredDepartments.map((department) => (
|
||||
<label
|
||||
key={department.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition hover:bg-[var(--surface-elevated)]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDepartmentIds.includes(department.id)}
|
||||
onChange={() => toggleDepartment(department.id)}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
<span className="min-w-0 truncate text-xs font-medium text-[var(--foreground)]">
|
||||
{department.name}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
) : (
|
||||
<p className="app-text-muted px-2 py-4 text-center text-xs">Отделы не найдены</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { MessageCircle, Edit2, CornerDownRight } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
@ -24,9 +24,10 @@ interface Comment {
|
||||
|
||||
interface DocumentCommentsProps {
|
||||
documentId: number;
|
||||
onCommentsChanged?: () => void;
|
||||
}
|
||||
|
||||
export function DocumentComments({ documentId }: DocumentCommentsProps) {
|
||||
export function DocumentComments({ documentId, onCommentsChanged }: DocumentCommentsProps) {
|
||||
const { user } = useUser();
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -36,11 +37,7 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
|
||||
const [editText, setEditText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadComments();
|
||||
}, [documentId]);
|
||||
|
||||
const loadComments = async () => {
|
||||
const loadComments = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.getDocumentComments(documentId);
|
||||
@ -75,7 +72,11 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [documentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadComments();
|
||||
}, [loadComments]);
|
||||
|
||||
const handleSubmitComment = async () => {
|
||||
if (!newComment.trim()) return;
|
||||
@ -92,6 +93,7 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
|
||||
setNewComment("");
|
||||
setReplyTo(null);
|
||||
await loadComments();
|
||||
onCommentsChanged?.();
|
||||
} catch (error) {
|
||||
console.error("Ошибка добавления комментария:", error);
|
||||
toast.error("Не удалось добавить комментарий");
|
||||
@ -105,7 +107,7 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await apiClient.updateDocumentComment(id, editText);
|
||||
await apiClient.updateDocumentComment(documentId, id, editText);
|
||||
toast.success("Комментарий обновлен");
|
||||
setEditingId(null);
|
||||
setEditText("");
|
||||
@ -122,9 +124,10 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
|
||||
if (!window.confirm("Удалить комментарий?")) return;
|
||||
|
||||
try {
|
||||
await apiClient.deleteDocumentComment(id);
|
||||
await apiClient.deleteDocumentComment(documentId, id);
|
||||
toast.success("Комментарий удален");
|
||||
await loadComments();
|
||||
onCommentsChanged?.();
|
||||
} catch (error) {
|
||||
console.error("Ошибка удаления комментария:", error);
|
||||
toast.error("Не удалось удалить комментарий");
|
||||
|
||||
@ -47,6 +47,7 @@ const DocumentPreviewPane = dynamic(
|
||||
interface DocumentDetailModalProps {
|
||||
document: Document | null;
|
||||
isOpen: boolean;
|
||||
initialTab?: TabType;
|
||||
onClose: () => void;
|
||||
onUpdate?: () => void;
|
||||
onEditMetadata?: () => void;
|
||||
@ -59,6 +60,7 @@ type TabType = "preview" | "info" | "activity" | "acknowledgements" | "comments"
|
||||
export function DocumentDetailModal({
|
||||
document,
|
||||
isOpen,
|
||||
initialTab,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onEditMetadata,
|
||||
@ -103,7 +105,11 @@ export function DocumentDetailModal({
|
||||
{ id: "comments" as TabType, label: "Комментарии", icon: MessageSquare },
|
||||
{ id: "related" as TabType, label: "Связанные", icon: Link2 },
|
||||
];
|
||||
const defaultTab: TabType = documentHasFile ? "preview" : "info";
|
||||
const defaultTab: TabType = initialTab && tabs.some((tab) => tab.id === initialTab)
|
||||
? initialTab
|
||||
: documentHasFile
|
||||
? "preview"
|
||||
: "info";
|
||||
const activeTab: TabType = activeTabState.documentId === documentId
|
||||
? activeTabState.value
|
||||
: defaultTab;
|
||||
@ -522,7 +528,10 @@ export function DocumentDetailModal({
|
||||
{currentActiveTab === "comments" && (
|
||||
<div className="p-6">
|
||||
<div className="app-surface rounded-xl p-6">
|
||||
<DocumentComments documentId={document.id} />
|
||||
<DocumentComments
|
||||
documentId={document.id}
|
||||
onCommentsChanged={() => onUpdate?.()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -790,7 +799,10 @@ export function DocumentDetailModal({
|
||||
{currentActiveTab === "comments" && (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="app-surface rounded-xl p-4 sm:p-6">
|
||||
<DocumentComments documentId={document.id} />
|
||||
<DocumentComments
|
||||
documentId={document.id}
|
||||
onCommentsChanged={() => onUpdate?.()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -6,19 +6,20 @@ import { apiClient } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertCircle,
|
||||
Building2,
|
||||
CheckCircle,
|
||||
FileText,
|
||||
Folder,
|
||||
Loader2,
|
||||
ScrollText,
|
||||
Tag as TagIcon,
|
||||
Upload,
|
||||
Users,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type { Department, Document, User } from '@/types/api';
|
||||
import { DocumentTagQuickCreate, type QuickDocumentTag } from './DocumentTagQuickCreate';
|
||||
import {
|
||||
DocumentAudienceSelector,
|
||||
type DocumentAudienceMode,
|
||||
} from './DocumentAudienceSelector';
|
||||
|
||||
type DocumentTagOption = NonNullable<Document["tags"]>[number];
|
||||
|
||||
@ -44,8 +45,11 @@ interface DocumentUpdatePayload {
|
||||
is_regulation?: boolean;
|
||||
sent_to_all?: boolean;
|
||||
acknowledgement_required?: boolean;
|
||||
acknowledgement_for_all?: boolean;
|
||||
recipient_ids?: number[];
|
||||
department_ids?: number[];
|
||||
acknowledgement_recipient_ids?: number[];
|
||||
acknowledgement_department_ids?: number[];
|
||||
}
|
||||
|
||||
interface DocumentMetadataEditorProps {
|
||||
@ -75,14 +79,6 @@ function formatFileSize(bytes?: number): string {
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} МБ`;
|
||||
}
|
||||
|
||||
function getEmployeeLabel(employee: User): string {
|
||||
const name = [employee.last_name, employee.first_name, employee.patronymic]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim();
|
||||
return employee.email ? `${name || employee.email} (${employee.email})` : name || `ID ${employee.id}`;
|
||||
}
|
||||
|
||||
function buildFolderOptionRows(folders: FolderOption[]): FolderOptionRow[] {
|
||||
const childrenByParent = new Map<number | null, FolderOption[]>();
|
||||
|
||||
@ -123,9 +119,17 @@ export function DocumentMetadataEditor({
|
||||
const [selectedFolder, setSelectedFolder] = useState<number | null>(document.folder?.id || null);
|
||||
const [isRegulation, setIsRegulation] = useState(Boolean(document.is_regulation));
|
||||
const [sentToAll, setSentToAll] = useState(Boolean(document.sent_to_all ?? true));
|
||||
const [acknowledgementRequired, setAcknowledgementRequired] = useState(Boolean(document.acknowledgement_required));
|
||||
const [acknowledgementMode, setAcknowledgementMode] = useState<DocumentAudienceMode>(
|
||||
!document.acknowledgement_required
|
||||
? 'none'
|
||||
: document.acknowledgement_for_all === false
|
||||
? 'restricted'
|
||||
: 'all'
|
||||
);
|
||||
const [selectedDepartments, setSelectedDepartments] = useState<number[]>([]);
|
||||
const [selectedRecipients, setSelectedRecipients] = useState<number[]>([]);
|
||||
const [acknowledgementDepartments, setAcknowledgementDepartments] = useState<number[]>([]);
|
||||
const [acknowledgementRecipients, setAcknowledgementRecipients] = useState<number[]>([]);
|
||||
|
||||
const [documentTags, setDocumentTags] = useState<DocumentTagOption[]>([]);
|
||||
const [folders, setFolders] = useState<FolderOption[]>([]);
|
||||
@ -149,9 +153,21 @@ export function DocumentMetadataEditor({
|
||||
setSelectedFolder(document.folder?.id || null);
|
||||
setIsRegulation(Boolean(document.is_regulation));
|
||||
setSentToAll(Boolean(document.sent_to_all ?? true));
|
||||
setAcknowledgementRequired(Boolean(document.acknowledgement_required));
|
||||
setAcknowledgementMode(
|
||||
!document.acknowledgement_required
|
||||
? 'none'
|
||||
: document.acknowledgement_for_all === false
|
||||
? 'restricted'
|
||||
: 'all'
|
||||
);
|
||||
setSelectedDepartments((document.departments || []).map((department) => department.id));
|
||||
setSelectedRecipients((document.recipients || []).map((recipient) => recipient.id));
|
||||
setAcknowledgementDepartments(
|
||||
(document.acknowledgement_departments || []).map((department) => department.id)
|
||||
);
|
||||
setAcknowledgementRecipients(
|
||||
(document.acknowledgement_recipients || []).map((recipient) => recipient.id)
|
||||
);
|
||||
setError(null);
|
||||
}, [isOpen, document]);
|
||||
|
||||
@ -193,6 +209,30 @@ export function DocumentMetadataEditor({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const acknowledgementEmployees = useMemo(() => {
|
||||
if (sentToAll) return employees;
|
||||
const departmentIds = new Set(selectedDepartments);
|
||||
const recipientIds = new Set(selectedRecipients);
|
||||
return employees.filter((employee) => (
|
||||
recipientIds.has(employee.id)
|
||||
|| (employee.departments || []).some((department) => departmentIds.has(department.id))
|
||||
));
|
||||
}, [employees, selectedDepartments, selectedRecipients, sentToAll]);
|
||||
|
||||
const acknowledgementDepartmentOptions = useMemo(() => {
|
||||
if (sentToAll) return departments;
|
||||
const departmentIds = new Set(selectedDepartments);
|
||||
return departments.filter((department) => departmentIds.has(department.id));
|
||||
}, [departments, selectedDepartments, sentToAll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sentToAll) return;
|
||||
const allowedEmployeeIds = new Set(acknowledgementEmployees.map((employee) => employee.id));
|
||||
const allowedDepartmentIds = new Set(selectedDepartments);
|
||||
setAcknowledgementRecipients((current) => current.filter((id) => allowedEmployeeIds.has(id)));
|
||||
setAcknowledgementDepartments((current) => current.filter((id) => allowedDepartmentIds.has(id)));
|
||||
}, [acknowledgementEmployees, selectedDepartments, sentToAll]);
|
||||
|
||||
const toggleTag = (tagId: number) => {
|
||||
setSelectedTags((prev) =>
|
||||
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId]
|
||||
@ -221,6 +261,15 @@ export function DocumentMetadataEditor({
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
acknowledgementMode === 'restricted'
|
||||
&& acknowledgementDepartments.length === 0
|
||||
&& acknowledgementRecipients.length === 0
|
||||
) {
|
||||
setError('Выберите хотя бы один отдел или сотрудника для ознакомления');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
@ -228,6 +277,12 @@ export function DocumentMetadataEditor({
|
||||
const currentTagIds = (document.tags || []).map((tag) => tag.id);
|
||||
const currentDepartmentIds = (document.departments || []).map((department) => department.id);
|
||||
const currentRecipientIds = (document.recipients || []).map((recipient) => recipient.id);
|
||||
const currentAcknowledgementDepartmentIds = (document.acknowledgement_departments || []).map(
|
||||
(department) => department.id
|
||||
);
|
||||
const currentAcknowledgementRecipientIds = (document.acknowledgement_recipients || []).map(
|
||||
(recipient) => recipient.id
|
||||
);
|
||||
const currentFolder = document.folder?.id || null;
|
||||
const currentSentToAll = Boolean(document.sent_to_all ?? true);
|
||||
|
||||
@ -259,9 +314,14 @@ export function DocumentMetadataEditor({
|
||||
updateData.sent_to_all = sentToAll;
|
||||
}
|
||||
|
||||
const acknowledgementRequired = acknowledgementMode !== 'none';
|
||||
const acknowledgementForAll = acknowledgementMode === 'all';
|
||||
if (acknowledgementRequired !== Boolean(document.acknowledgement_required)) {
|
||||
updateData.acknowledgement_required = acknowledgementRequired;
|
||||
}
|
||||
if (acknowledgementForAll !== Boolean(document.acknowledgement_for_all ?? true)) {
|
||||
updateData.acknowledgement_for_all = acknowledgementForAll;
|
||||
}
|
||||
|
||||
if (!arraysEqual(currentTagIds, selectedTags)) {
|
||||
updateData.tag_ids = selectedTags;
|
||||
@ -277,6 +337,15 @@ export function DocumentMetadataEditor({
|
||||
}
|
||||
}
|
||||
|
||||
if (acknowledgementMode === 'restricted') {
|
||||
if (!arraysEqual(currentAcknowledgementDepartmentIds, acknowledgementDepartments)) {
|
||||
updateData.acknowledgement_department_ids = acknowledgementDepartments;
|
||||
}
|
||||
if (!arraysEqual(currentAcknowledgementRecipientIds, acknowledgementRecipients)) {
|
||||
updateData.acknowledgement_recipient_ids = acknowledgementRecipients;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
toast.info('Нет изменений для сохранения');
|
||||
onClose();
|
||||
@ -518,125 +587,36 @@ export function DocumentMetadataEditor({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--foreground)]">
|
||||
<Users className="h-4 w-4" />
|
||||
Доступ и ознакомление
|
||||
<section className="space-y-6">
|
||||
<DocumentAudienceSelector
|
||||
kind="access"
|
||||
mode={sentToAll ? 'all' : 'restricted'}
|
||||
onModeChange={(mode) => setSentToAll(mode === 'all')}
|
||||
employees={employees}
|
||||
departments={departments}
|
||||
selectedEmployeeIds={selectedRecipients}
|
||||
selectedDepartmentIds={selectedDepartments}
|
||||
onSelectedEmployeeIdsChange={setSelectedRecipients}
|
||||
onSelectedDepartmentIdsChange={setSelectedDepartments}
|
||||
loading={loadingReferences}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
<div className="app-divider border-t pt-5">
|
||||
<DocumentAudienceSelector
|
||||
kind="acknowledgement"
|
||||
mode={acknowledgementMode}
|
||||
onModeChange={setAcknowledgementMode}
|
||||
employees={acknowledgementEmployees}
|
||||
departments={acknowledgementDepartmentOptions}
|
||||
selectedEmployeeIds={acknowledgementRecipients}
|
||||
selectedDepartmentIds={acknowledgementDepartments}
|
||||
onSelectedEmployeeIdsChange={setAcknowledgementRecipients}
|
||||
onSelectedDepartmentIdsChange={setAcknowledgementDepartments}
|
||||
loading={loadingReferences}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<div className="app-surface-muted flex items-start gap-3 rounded-lg p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="metadataSentToAll"
|
||||
checked={sentToAll}
|
||||
onChange={(event) => {
|
||||
setSentToAll(event.target.checked);
|
||||
if (event.target.checked) {
|
||||
setSelectedDepartments([]);
|
||||
setSelectedRecipients([]);
|
||||
}
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="mt-0.5 h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)] disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="metadataSentToAll" className="block cursor-pointer text-sm font-medium text-[var(--foreground)]">
|
||||
Для всей компании
|
||||
</label>
|
||||
<p className="app-text-muted mt-0.5 text-xs">Документ доступен всем активным сотрудникам.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-surface-muted flex items-start gap-3 rounded-lg p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="metadataAcknowledgementRequired"
|
||||
checked={acknowledgementRequired}
|
||||
onChange={(event) => setAcknowledgementRequired(event.target.checked)}
|
||||
disabled={isSaving}
|
||||
className="mt-0.5 h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)] disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="metadataAcknowledgementRequired" className="block cursor-pointer text-sm font-medium text-[var(--foreground)]">
|
||||
<CheckCircle className="mr-1 inline h-4 w-4" />
|
||||
Требуется ознакомление
|
||||
</label>
|
||||
<p className="app-text-muted mt-0.5 text-xs">Получатели должны подтвердить прочтение документа.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!sentToAll && (
|
||||
<div className="app-surface-muted grid grid-cols-1 gap-4 rounded-lg p-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="metadataDepartments" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
|
||||
<Building2 className="mr-1 inline h-4 w-4" />
|
||||
Отделы
|
||||
</label>
|
||||
<select
|
||||
id="metadataDepartments"
|
||||
multiple
|
||||
value={selectedDepartments.map(String)}
|
||||
onChange={(event) => {
|
||||
const values = Array.from(event.target.selectedOptions, (option) => Number(option.value));
|
||||
setSelectedDepartments(values);
|
||||
}}
|
||||
disabled={loadingReferences || isSaving}
|
||||
className="app-select w-full rounded-lg px-3 py-2 text-sm"
|
||||
size={7}
|
||||
>
|
||||
{departments.length === 0 ? (
|
||||
<option disabled>Нет доступных отделов</option>
|
||||
) : (
|
||||
departments.map((department) => (
|
||||
<option key={department.id} value={department.id}>
|
||||
{department.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<p className="app-text-muted mt-1 text-xs">Удерживайте Ctrl/Cmd для выбора нескольких.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="metadataRecipients" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
|
||||
<Users className="mr-1 inline h-4 w-4" />
|
||||
Сотрудники
|
||||
</label>
|
||||
<select
|
||||
id="metadataRecipients"
|
||||
multiple
|
||||
value={selectedRecipients.map(String)}
|
||||
onChange={(event) => {
|
||||
const values = Array.from(event.target.selectedOptions, (option) => Number(option.value));
|
||||
setSelectedRecipients(values);
|
||||
}}
|
||||
disabled={loadingReferences || isSaving}
|
||||
className="app-select w-full rounded-lg px-3 py-2 text-sm"
|
||||
size={7}
|
||||
>
|
||||
{employees.length === 0 ? (
|
||||
<option disabled>Нет доступных сотрудников</option>
|
||||
) : (
|
||||
employees.map((employee) => (
|
||||
<option key={employee.id} value={employee.id}>
|
||||
{getEmployeeLabel(employee)}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<p className="app-text-muted mt-1 text-xs">Можно выбрать сотрудников напрямую, отделы или оба варианта.</p>
|
||||
</div>
|
||||
|
||||
{selectedDepartments.length === 0 && selectedRecipients.length === 0 && (
|
||||
<div className="app-feedback-warning flex items-start gap-2 rounded-lg p-3 text-xs lg:col-span-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>Выберите хотя бы один отдел или сотрудника.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="app-divider sticky bottom-0 z-10 -mx-4 flex items-center justify-end gap-3 border-t bg-[var(--surface-elevated)] px-4 pt-4 sm:-mx-6 sm:px-6">
|
||||
|
||||
@ -9,28 +9,18 @@ import {
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
Users,
|
||||
Building2,
|
||||
CheckCircle,
|
||||
ScrollText,
|
||||
Tag as TagIcon,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { Department, User } from "@/types/api";
|
||||
import { toast } from "sonner";
|
||||
import { processDocument, needsProcessing, type ProcessingProgress } from "@/lib/document-utils";
|
||||
import { DocumentTagQuickCreate, type QuickDocumentTag } from "./DocumentTagQuickCreate";
|
||||
|
||||
interface Department {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email?: string;
|
||||
}
|
||||
import {
|
||||
DocumentAudienceSelector,
|
||||
type DocumentAudienceMode,
|
||||
} from "./DocumentAudienceSelector";
|
||||
|
||||
interface DocumentTagOption {
|
||||
id: number;
|
||||
@ -54,6 +44,7 @@ interface DocumentUploadFormProps {
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
currentFolderId?: number | null;
|
||||
defaultIsRegulation?: boolean;
|
||||
}
|
||||
|
||||
interface UploadFileItem {
|
||||
@ -127,7 +118,12 @@ const STAGE_MESSAGES: Record<string, string> = {
|
||||
complete: "Обработка завершена",
|
||||
};
|
||||
|
||||
export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: DocumentUploadFormProps) {
|
||||
export function DocumentUploadForm({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
currentFolderId,
|
||||
defaultIsRegulation = false,
|
||||
}: DocumentUploadFormProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [uploadItems, setUploadItems] = useState<UploadFileItem[]>([]);
|
||||
@ -138,14 +134,16 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
|
||||
// New fields for extended functionality
|
||||
const [sentToAll, setSentToAll] = useState(true);
|
||||
const [acknowledgementRequired, setAcknowledgementRequired] = useState(false);
|
||||
const [acknowledgementMode, setAcknowledgementMode] = useState<DocumentAudienceMode>("all");
|
||||
const [selectedDepartments, setSelectedDepartments] = useState<number[]>([]);
|
||||
const [selectedRecipients, setSelectedRecipients] = useState<number[]>([]);
|
||||
const [acknowledgementDepartments, setAcknowledgementDepartments] = useState<number[]>([]);
|
||||
const [acknowledgementRecipients, setAcknowledgementRecipients] = useState<number[]>([]);
|
||||
|
||||
// Metadata fields
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(currentFolderId ?? null);
|
||||
const [selectedTags, setSelectedTags] = useState<number[]>([]);
|
||||
const [isRegulation, setIsRegulation] = useState(false);
|
||||
const [isRegulation, setIsRegulation] = useState(defaultIsRegulation);
|
||||
|
||||
// Data for selects
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
@ -178,7 +176,11 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
|
||||
try {
|
||||
setLoadingEmployees(true);
|
||||
const empResponse = await apiClient.getEmployees({ limit: 1000 });
|
||||
const empResponse = await apiClient.getEmployees({
|
||||
limit: 1000,
|
||||
is_active: true,
|
||||
ordering: "last_name,first_name",
|
||||
});
|
||||
setEmployees(empResponse.results || empResponse);
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки сотрудников:", err);
|
||||
@ -211,6 +213,30 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const acknowledgementEmployees = useMemo(() => {
|
||||
if (sentToAll) return employees;
|
||||
const departmentIds = new Set(selectedDepartments);
|
||||
const recipientIds = new Set(selectedRecipients);
|
||||
return employees.filter((employee) => (
|
||||
recipientIds.has(employee.id)
|
||||
|| (employee.departments || []).some((department) => departmentIds.has(department.id))
|
||||
));
|
||||
}, [employees, selectedDepartments, selectedRecipients, sentToAll]);
|
||||
|
||||
const acknowledgementDepartmentOptions = useMemo(() => {
|
||||
if (sentToAll) return departments;
|
||||
const departmentIds = new Set(selectedDepartments);
|
||||
return departments.filter((department) => departmentIds.has(department.id));
|
||||
}, [departments, selectedDepartments, sentToAll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sentToAll) return;
|
||||
const allowedEmployeeIds = new Set(acknowledgementEmployees.map((employee) => employee.id));
|
||||
const allowedDepartmentIds = new Set(selectedDepartments);
|
||||
setAcknowledgementRecipients((current) => current.filter((id) => allowedEmployeeIds.has(id)));
|
||||
setAcknowledgementDepartments((current) => current.filter((id) => allowedDepartmentIds.has(id)));
|
||||
}, [acknowledgementEmployees, selectedDepartments, sentToAll]);
|
||||
|
||||
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length === 0) return;
|
||||
|
||||
@ -230,7 +256,7 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
|
||||
setIsProcessing(true);
|
||||
setProcessingProgress({
|
||||
stage: "compressing",
|
||||
stage: "extracting_text",
|
||||
progress: 0,
|
||||
message: "Начало обработки...",
|
||||
});
|
||||
@ -244,7 +270,6 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
try {
|
||||
const result = await processDocument(item.file, {
|
||||
enableOCR: true,
|
||||
enableCompression: true,
|
||||
enableTextExtraction: true,
|
||||
enableThumbnail: true,
|
||||
onProgress: (progress) => {
|
||||
@ -340,7 +365,16 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
|
||||
// Validate recipients if sent_to_all is false
|
||||
if (!sentToAll && selectedDepartments.length === 0 && selectedRecipients.length === 0) {
|
||||
setError("Укажите получателей или отделы, либо выберите 'Отправить всем'");
|
||||
setError("Выберите хотя бы один отдел или сотрудника с доступом к документу");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
acknowledgementMode === "restricted"
|
||||
&& acknowledgementDepartments.length === 0
|
||||
&& acknowledgementRecipients.length === 0
|
||||
) {
|
||||
setError("Выберите хотя бы один отдел или сотрудника для ознакомления");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -356,7 +390,12 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
is_regulation: isRegulation,
|
||||
department_ids: sentToAll ? undefined : selectedDepartments,
|
||||
recipient_ids: sentToAll ? undefined : selectedRecipients,
|
||||
acknowledgement_required: acknowledgementRequired,
|
||||
acknowledgement_required: acknowledgementMode !== "none",
|
||||
acknowledgement_for_all: acknowledgementMode === "all",
|
||||
acknowledgement_department_ids:
|
||||
acknowledgementMode === "restricted" ? acknowledgementDepartments : undefined,
|
||||
acknowledgement_recipient_ids:
|
||||
acknowledgementMode === "restricted" ? acknowledgementRecipients : undefined,
|
||||
tag_ids: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
});
|
||||
} else {
|
||||
@ -376,7 +415,12 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
is_regulation: isRegulation,
|
||||
department_ids: sentToAll ? undefined : selectedDepartments,
|
||||
recipient_ids: sentToAll ? undefined : selectedRecipients,
|
||||
acknowledgement_required: acknowledgementRequired,
|
||||
acknowledgement_required: acknowledgementMode !== "none",
|
||||
acknowledgement_for_all: acknowledgementMode === "all",
|
||||
acknowledgement_department_ids:
|
||||
acknowledgementMode === "restricted" ? acknowledgementDepartments : undefined,
|
||||
acknowledgement_recipient_ids:
|
||||
acknowledgementMode === "restricted" ? acknowledgementRecipients : undefined,
|
||||
tag_ids: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
});
|
||||
}
|
||||
@ -396,10 +440,12 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
setUploadItems([]);
|
||||
setSelectedFolderId(currentFolderId ?? null);
|
||||
setSentToAll(true);
|
||||
setIsRegulation(false);
|
||||
setAcknowledgementRequired(false);
|
||||
setIsRegulation(defaultIsRegulation);
|
||||
setAcknowledgementMode("all");
|
||||
setSelectedDepartments([]);
|
||||
setSelectedRecipients([]);
|
||||
setAcknowledgementDepartments([]);
|
||||
setAcknowledgementRecipients([]);
|
||||
setSelectedTags([]);
|
||||
|
||||
if (onSuccess) {
|
||||
@ -717,140 +763,37 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="app-divider border-t pt-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-[var(--foreground)]">Настройки доступа и уведомлений</h3>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<DocumentAudienceSelector
|
||||
kind="access"
|
||||
mode={sentToAll ? "all" : "restricted"}
|
||||
onModeChange={(mode) => setSentToAll(mode === "all")}
|
||||
employees={employees}
|
||||
departments={departments}
|
||||
selectedEmployeeIds={selectedRecipients}
|
||||
selectedDepartmentIds={selectedDepartments}
|
||||
onSelectedEmployeeIdsChange={setSelectedRecipients}
|
||||
onSelectedDepartmentIdsChange={setSelectedDepartments}
|
||||
loading={loadingEmployees || loadingDepartments}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
{/* Sent to All Toggle */}
|
||||
<div className="app-surface-muted flex items-start gap-3 rounded-lg p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="sentToAll"
|
||||
checked={sentToAll}
|
||||
onChange={(e) => {
|
||||
setSentToAll(e.target.checked);
|
||||
if (e.target.checked) {
|
||||
setSelectedDepartments([]);
|
||||
setSelectedRecipients([]);
|
||||
}
|
||||
}}
|
||||
className="mt-0.5 h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)]"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="sentToAll" className="block cursor-pointer text-sm font-medium text-[var(--foreground)]">
|
||||
Отправить всем сотрудникам
|
||||
</label>
|
||||
<p className="app-text-muted mt-0.5 text-xs">
|
||||
Документ будет доступен всем активным сотрудникам
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recipients section - only show when sentToAll is false */}
|
||||
{!sentToAll && (
|
||||
<div className="app-surface-muted space-y-4 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-[var(--foreground)]">
|
||||
<Users size={16} />
|
||||
<span>Выберите получателей</span>
|
||||
<div className="app-divider border-t pt-5">
|
||||
<DocumentAudienceSelector
|
||||
kind="acknowledgement"
|
||||
mode={acknowledgementMode}
|
||||
onModeChange={setAcknowledgementMode}
|
||||
employees={acknowledgementEmployees}
|
||||
departments={acknowledgementDepartmentOptions}
|
||||
selectedEmployeeIds={acknowledgementRecipients}
|
||||
selectedDepartmentIds={acknowledgementDepartments}
|
||||
onSelectedEmployeeIdsChange={setAcknowledgementRecipients}
|
||||
onSelectedDepartmentIdsChange={setAcknowledgementDepartments}
|
||||
loading={loadingEmployees || loadingDepartments}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Departments */}
|
||||
<div>
|
||||
<label htmlFor="departments" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
|
||||
<Building2 size={14} className="mr-1 inline" />
|
||||
Отделы
|
||||
</label>
|
||||
<select
|
||||
id="departments"
|
||||
multiple
|
||||
value={selectedDepartments.map(String)}
|
||||
onChange={(e) => {
|
||||
const values = Array.from(e.target.selectedOptions, (option) => Number(option.value));
|
||||
setSelectedDepartments(values);
|
||||
}}
|
||||
disabled={loadingDepartments}
|
||||
className="app-select w-full rounded-lg px-3 py-2 text-sm"
|
||||
size={5}
|
||||
>
|
||||
{loadingDepartments ? (
|
||||
<option disabled>Загрузка...</option>
|
||||
) : departments.length === 0 ? (
|
||||
<option disabled>Нет доступных отделов</option>
|
||||
) : (
|
||||
departments.map((dept) => (
|
||||
<option key={dept.id} value={dept.id}>
|
||||
{dept.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Удерживайте Ctrl/Cmd для выбора нескольких
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Recipients */}
|
||||
<div>
|
||||
<label htmlFor="recipients" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
|
||||
<Users size={14} className="mr-1 inline" />
|
||||
Конкретные сотрудники
|
||||
</label>
|
||||
<select
|
||||
id="recipients"
|
||||
multiple
|
||||
value={selectedRecipients.map(String)}
|
||||
onChange={(e) => {
|
||||
const values = Array.from(e.target.selectedOptions, (option) => Number(option.value));
|
||||
setSelectedRecipients(values);
|
||||
}}
|
||||
disabled={loadingEmployees}
|
||||
className="app-select w-full rounded-lg px-3 py-2 text-sm"
|
||||
size={8}
|
||||
>
|
||||
{loadingEmployees ? (
|
||||
<option disabled>Загрузка...</option>
|
||||
) : employees.length === 0 ? (
|
||||
<option disabled>Нет доступных сотрудников</option>
|
||||
) : (
|
||||
employees.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>
|
||||
{emp.last_name} {emp.first_name} {emp.email && `(${emp.email})`}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Удерживайте Ctrl/Cmd для выбора нескольких
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedDepartments.length === 0 && selectedRecipients.length === 0 && (
|
||||
<div className="app-feedback-warning flex items-start gap-2 rounded-lg p-3 text-xs">
|
||||
<AlertCircle size={14} className="mt-0.5 shrink-0" />
|
||||
<span>Выберите хотя бы один отдел или сотрудника</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Acknowledgement Required */}
|
||||
<div className="app-surface-muted flex items-start gap-3 rounded-lg p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="acknowledgementRequired"
|
||||
checked={acknowledgementRequired}
|
||||
onChange={(e) => setAcknowledgementRequired(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)]"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="acknowledgementRequired" className="block cursor-pointer text-sm font-medium text-[var(--foreground)]">
|
||||
<CheckCircle size={14} className="mr-1 inline" />
|
||||
Требуется подтверждение ознакомления
|
||||
</label>
|
||||
<p className="app-text-muted mt-0.5 text-xs">
|
||||
Получатели должны будут подтвердить, что ознакомились с документом
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -870,8 +813,8 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
: uploadItems.length > 1
|
||||
? `Загрузить ${uploadItems.length} документов`
|
||||
: uploadItems.length === 1
|
||||
? "Загрузить документ"
|
||||
: "Создать документ"}
|
||||
? isRegulation ? "Загрузить регламент" : "Загрузить документ"
|
||||
: isRegulation ? "Создать регламент" : "Создать документ"}
|
||||
</button>
|
||||
|
||||
{onCancel && (
|
||||
|
||||
@ -35,7 +35,7 @@ export default function CommentItem({
|
||||
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
const updated = await apiClient.updateDocumentComment(comment.id, editText);
|
||||
const updated = await apiClient.updateDocumentComment(comment.document, comment.id, editText);
|
||||
onCommentUpdated(updated);
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
@ -49,7 +49,7 @@ export default function CommentItem({
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await apiClient.deleteDocumentComment(comment.id);
|
||||
await apiClient.deleteDocumentComment(comment.document, comment.id);
|
||||
onCommentDeleted(comment.id);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete comment:', err);
|
||||
|
||||
37
frontend/src/components/feed/ExpandableFeedText.tsx
Normal file
37
frontend/src/components/feed/ExpandableFeedText.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
type ExpandableFeedTextProps = {
|
||||
text: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ExpandableFeedText({ text, className = "" }: ExpandableFeedTextProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isLong = text.length > 320 || text.split("\n").length > 6;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p
|
||||
className={`app-text-wrap whitespace-pre-line text-sm leading-6 text-[var(--foreground)] ${
|
||||
isLong && !expanded ? "line-clamp-6" : ""
|
||||
} ${className}`}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
{isLong ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
className="app-accent-text mt-1 inline-flex items-center gap-1 py-1 text-xs font-medium hover:text-[var(--accent-primary-strong)]"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
{expanded ? "Скрыть" : "Подробнее"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import type { ReactNode } from "react";
|
||||
import { Heart, MessageSquare, Paperclip, Pin } from "lucide-react";
|
||||
|
||||
import { RequestAvatar } from "@/components/requests/RequestAvatar";
|
||||
import { ExpandableFeedText } from "@/components/feed/ExpandableFeedText";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import type { Post } from "@/types/api";
|
||||
|
||||
@ -136,9 +137,7 @@ export function FeedPostCard({
|
||||
) : null}
|
||||
|
||||
{postText ? (
|
||||
<p className="app-text-wrap whitespace-pre-line text-sm leading-6 text-[var(--foreground)]">
|
||||
{postText}
|
||||
</p>
|
||||
<ExpandableFeedText text={postText} />
|
||||
) : null}
|
||||
|
||||
{resolvedImageSrc ? (
|
||||
|
||||
387
frontend/src/components/feed/FeedRegulationCard.tsx
Normal file
387
frontend/src/components/feed/FeedRegulationCard.tsx
Normal file
@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Link2,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
ScrollText,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
import { ExpandableFeedText } from "@/components/feed/ExpandableFeedText";
|
||||
import { RequestAvatar } from "@/components/requests/RequestAvatar";
|
||||
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
|
||||
import { canPreviewDocument } from "@/lib/document-preview";
|
||||
import { userProfileLink } from "@/lib/shared";
|
||||
import type { Document } from "@/types/api";
|
||||
|
||||
type FeedRegulationCardProps = {
|
||||
currentUserId?: number | null;
|
||||
document: Document;
|
||||
isAcknowledging?: boolean;
|
||||
onAcknowledge: (document: Document) => void;
|
||||
onDelete: (document: Document) => void;
|
||||
onEdit: (document: Document) => void;
|
||||
onLinkTask: (document: Document) => void;
|
||||
onMove: (document: Document) => void;
|
||||
onOpen: (document: Document) => void;
|
||||
onOpenAcknowledgements: (document: Document) => void;
|
||||
onOpenComments: (document: Document) => void;
|
||||
onPreview: (document: Document) => void;
|
||||
};
|
||||
|
||||
function formatTimeAgo(value?: string) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
if (!Number.isFinite(diffMs) || diffMs < 60_000) return "только что";
|
||||
const diffHours = Math.floor(diffMs / 3_600_000);
|
||||
if (diffHours < 24) return `${diffHours} ч. назад`;
|
||||
return `${Math.floor(diffHours / 24)} дн. назад`;
|
||||
}
|
||||
|
||||
function uploaderName(document: Document) {
|
||||
const uploader = document.uploaded_by || document.created_by;
|
||||
const name = `${uploader?.last_name || ""} ${uploader?.first_name || ""}`.trim();
|
||||
return name || uploader?.email || "Сотрудник";
|
||||
}
|
||||
|
||||
export function FeedRegulationCard({
|
||||
currentUserId,
|
||||
document,
|
||||
isAcknowledging = false,
|
||||
onAcknowledge,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onLinkTask,
|
||||
onMove,
|
||||
onOpen,
|
||||
onOpenAcknowledgements,
|
||||
onOpenComments,
|
||||
onPreview,
|
||||
}: FeedRegulationCardProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const uploader = document.uploaded_by || document.created_by;
|
||||
const name = uploaderName(document);
|
||||
const initials = `${uploader?.last_name?.[0] || ""}${uploader?.first_name?.[0] || ""}`.toUpperCase() || "Р";
|
||||
const linkedTasks = document.linked_tasks || [];
|
||||
const uploadedAt = document.uploaded_at || document.created_at;
|
||||
const acknowledgementRequiredForUser =
|
||||
document.acknowledgement_required_for_user ?? document.acknowledgement_required;
|
||||
const acknowledgedCount = document.acknowledged_count || 0;
|
||||
const acknowledgementTotal = document.acknowledgement_total || 0;
|
||||
const commentsCount = document.comments_count || 0;
|
||||
const hasPreview = Boolean(
|
||||
document.file_url && canPreviewDocument(document.file_name || document.title),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setMenuOpen(false);
|
||||
};
|
||||
|
||||
window.document.addEventListener("mousedown", handlePointerDown);
|
||||
window.document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
window.document.removeEventListener("mousedown", handlePointerDown);
|
||||
window.document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<article
|
||||
className="app-surface rounded-2xl p-5"
|
||||
style={{ borderColor: "var(--regulation-border)" }}
|
||||
>
|
||||
<header className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<RequestAvatar
|
||||
alt={name}
|
||||
fallback={initials}
|
||||
size="lg"
|
||||
src={uploader?.avatar}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
{uploader ? (
|
||||
<Link
|
||||
href={userProfileLink(uploader, currentUserId)}
|
||||
className="block truncate text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="truncate text-sm font-semibold text-[var(--foreground)]">{name}</p>
|
||||
)}
|
||||
<div className="app-text-muted flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span>{formatTimeAgo(uploadedAt)}</span>
|
||||
<span className="app-selected app-accent-text inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium">
|
||||
<ScrollText size={11} />
|
||||
Регламент
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={menuRef} className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((current) => !current)}
|
||||
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
|
||||
title="Действия с регламентом"
|
||||
aria-label={`Действия с регламентом ${document.title}`}
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
className={`transition-transform duration-200 ${menuOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{menuOpen ? (
|
||||
<div className="app-menu absolute right-0 top-full z-30 mt-2 w-56 rounded-xl py-1.5" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onOpen(document);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<FileText size={14} />
|
||||
Детали
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onEdit(document);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onMove(document);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
Переместить
|
||||
</button>
|
||||
{hasPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onPreview(document);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Eye size={14} />
|
||||
Предпросмотр
|
||||
</button>
|
||||
) : null}
|
||||
{document.file_url ? (
|
||||
<a
|
||||
href={document.file_url}
|
||||
download={document.file_name || document.title}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Download size={14} />
|
||||
Скачать
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onOpenComments(document);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
Комментарии ({commentsCount})
|
||||
</button>
|
||||
{document.acknowledgement_required ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onOpenAcknowledgements(document);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Users size={14} />
|
||||
Ведомость ({acknowledgedCount}/{acknowledgementTotal})
|
||||
</button>
|
||||
) : null}
|
||||
{acknowledgementRequiredForUser && !document.is_acknowledged ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isAcknowledging}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onAcknowledge(document);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
|
||||
>
|
||||
{isAcknowledging ? <Loader2 size={14} className="animate-spin" /> : <CheckCircle2 size={14} />}
|
||||
Ознакомился(лась)
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onLinkTask(document);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Link2 size={14} />
|
||||
Связать с задачей
|
||||
</button>
|
||||
<div className="my-1 border-t border-[var(--border-subtle)]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onDelete(document);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)]"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(document)}
|
||||
className="block w-full text-left"
|
||||
>
|
||||
<h3 className="app-text-wrap text-base font-semibold text-[var(--foreground)]">
|
||||
{document.title}
|
||||
</h3>
|
||||
</button>
|
||||
{document.description ? (
|
||||
<ExpandableFeedText text={document.description} className="mt-1" />
|
||||
) : null}
|
||||
|
||||
<div className="app-text-muted mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
{document.sent_to_all ? (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1">
|
||||
<Users size={11} /> Для всей компании
|
||||
</span>
|
||||
) : null}
|
||||
{(document.departments || []).slice(0, 3).map((department) => (
|
||||
<span key={department.id} className="app-badge rounded-full px-2 py-1">
|
||||
{department.name}
|
||||
</span>
|
||||
))}
|
||||
{acknowledgementRequiredForUser ? (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 ${document.is_acknowledged ? "app-feedback-success" : "app-feedback-warning"}`}>
|
||||
<CheckCircle2 size={11} />
|
||||
{document.is_acknowledged ? "Ознакомлен" : "Требует ознакомления"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<footer className="app-text-muted mt-4 flex flex-wrap items-center gap-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{acknowledgementRequiredForUser && !document.is_acknowledged ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAcknowledge(document)}
|
||||
disabled={isAcknowledging}
|
||||
className="app-action-ghost inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-60"
|
||||
title="Ознакомился(лась)"
|
||||
aria-label="Ознакомился(лась)"
|
||||
>
|
||||
{isAcknowledging ? <Loader2 size={16} className="animate-spin" /> : <CheckCircle2 size={16} />}
|
||||
</button>
|
||||
) : null}
|
||||
{document.file_url ? (
|
||||
<a
|
||||
href={document.file_url}
|
||||
download={document.file_name || document.title}
|
||||
className="app-action-ghost inline-flex h-9 w-9 items-center justify-center rounded-lg"
|
||||
title="Скачать"
|
||||
aria-label="Скачать регламент"
|
||||
>
|
||||
<Download size={16} />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{linkedTasks.length > 0 ? (
|
||||
<div className="flex max-w-full flex-wrap gap-1.5">
|
||||
{linkedTasks.slice(0, 3).map((task) => (
|
||||
<TaskLinkPill
|
||||
key={task.link_id || task.id}
|
||||
task={task}
|
||||
maxTitleClassName="max-w-40"
|
||||
/>
|
||||
))}
|
||||
{linkedTasks.length > 3 ? (
|
||||
<span className="app-badge rounded-full px-2 py-0.5 text-[11px] font-medium">
|
||||
+{linkedTasks.length - 3}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenComments(document)}
|
||||
className="app-action-ghost inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-2"
|
||||
title="Комментарии"
|
||||
aria-label={`Комментарии: ${commentsCount}`}
|
||||
>
|
||||
<MessageSquare size={16} className="app-text-muted" />
|
||||
<span>{commentsCount}</span>
|
||||
</button>
|
||||
{document.acknowledgement_required ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenAcknowledgements(document)}
|
||||
className="app-action-ghost inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-2"
|
||||
title="Ведомость ознакомлений"
|
||||
aria-label={`Ведомость ознакомлений: ${acknowledgedCount} из ${acknowledgementTotal}`}
|
||||
>
|
||||
<Users size={16} />
|
||||
<span>{acknowledgedCount}/{acknowledgementTotal}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@ -2,8 +2,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, Check, CheckCircle2, CircleDot, ExternalLink, MessageSquare, Minus, Plus, RotateCcw, Save, SlidersHorizontal, X } from "lucide-react";
|
||||
import { AlertTriangle, Check, CheckCircle2, CircleDot, Download, ExternalLink, MessageSquare, Minus, Paperclip, Plus, RotateCcw, Save, SlidersHorizontal, Trash2, X } from "lucide-react";
|
||||
import { RequestAvatar } from "@/components/requests/RequestAvatar";
|
||||
import { ProcurementRequestHistory } from "@/components/procurement/ProcurementRequestHistory";
|
||||
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
@ -11,7 +12,7 @@ import { cleanLinkRows, linkHref, toLinkRows } from "@/lib/procurementLinks";
|
||||
import { getExpectedDeliveryDateBadgeClass } from "@/lib/procurementDates";
|
||||
import { getProcurementUnitLabel } from "@/lib/procurementUnits";
|
||||
import { formatDate, formatMoney, userProfileLink } from "@/lib/shared";
|
||||
import type { ProcurementItem, ProcurementItemComment, ProcurementItemExecutionStatus, ProcurementRequest, User } from "@/types/api";
|
||||
import type { ProcurementItem, ProcurementItemAttachment, ProcurementItemComment, ProcurementItemExecutionStatus, ProcurementRequest, User } from "@/types/api";
|
||||
|
||||
interface ProcurementRequestDetailContentProps {
|
||||
currentUserId?: number | null;
|
||||
@ -37,6 +38,9 @@ interface ProcurementRequestDetailContentProps {
|
||||
onItemCommentDraftChange?: (itemId: number, value: string) => void;
|
||||
onAddItemComment?: (requestId: number, itemId: number) => void | Promise<void>;
|
||||
onDeleteItemComment?: (requestId: number, itemId: number, commentId: number) => void | Promise<void>;
|
||||
onDownloadItemAttachment?: (itemId: number, attachment: ProcurementItemAttachment) => void | Promise<void>;
|
||||
onUploadItemAttachments?: (requestId: number, itemId: number, files: File[]) => void | Promise<unknown>;
|
||||
onDeleteItemAttachment?: (requestId: number, itemId: number, attachmentId: number) => void | Promise<unknown>;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
@ -178,6 +182,9 @@ interface ProcurementItemCardProps {
|
||||
onCommentDraftChange?: (itemId: number, value: string) => void;
|
||||
onAddComment?: (requestId: number, itemId: number) => void | Promise<void>;
|
||||
onDeleteComment?: (requestId: number, itemId: number, commentId: number) => void | Promise<void>;
|
||||
onDownloadAttachment?: (itemId: number, attachment: ProcurementItemAttachment) => void | Promise<void>;
|
||||
onUploadAttachments?: (requestId: number, itemId: number, files: File[]) => void | Promise<unknown>;
|
||||
onDeleteAttachment?: (requestId: number, itemId: number, attachmentId: number) => void | Promise<unknown>;
|
||||
}
|
||||
|
||||
function ProcurementItemCard({
|
||||
@ -204,6 +211,9 @@ function ProcurementItemCard({
|
||||
onCommentDraftChange,
|
||||
onAddComment,
|
||||
onDeleteComment,
|
||||
onDownloadAttachment,
|
||||
onUploadAttachments,
|
||||
onDeleteAttachment,
|
||||
}: ProcurementItemCardProps) {
|
||||
const [draft, setDraft] = useState<ItemProcessingDraft>(() => normalizeItemDraft(item));
|
||||
const [processingOpen, setProcessingOpen] = useState(false);
|
||||
@ -214,6 +224,7 @@ function ProcurementItemCard({
|
||||
const links = Array.isArray(item.links) ? item.links.filter(Boolean) : [];
|
||||
const expectedDeliveryDates = toDateRows(item.expected_delivery_dates);
|
||||
const commentsTotal = item.comments_count ?? comments.length;
|
||||
const attachments = item.attachments || [];
|
||||
const requestedQuantity = Math.max(0, Math.trunc(toNumber(item.quantity)));
|
||||
const orderedQuantity = effectiveOrderedQuantity(item, requestedQuantity);
|
||||
const receivedQuantity = effectiveReceivedQuantity(item, requestedQuantity);
|
||||
@ -452,6 +463,65 @@ function ProcurementItemCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{attachments.length > 0 || (canEditItemProcessing && onUploadAttachments) ? (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<p className="app-text-muted text-[11px] font-medium">Файлы</p>
|
||||
{canEditItemProcessing && onUploadAttachments ? (
|
||||
<label className="app-action-ghost inline-flex cursor-pointer items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium">
|
||||
<Plus size={11} /> Добавить
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
void onUploadAttachments(requestId, item.id, files);
|
||||
}
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
{attachments.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{attachments.map((attachment) => (
|
||||
<span
|
||||
key={attachment.id}
|
||||
className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDownloadAttachment?.(item.id, attachment)}
|
||||
className="inline-flex min-w-0 items-center gap-1"
|
||||
title={`Скачать ${attachment.file_name}`}
|
||||
>
|
||||
<Paperclip size={11} className="shrink-0" />
|
||||
<span className="max-w-48 truncate">{attachment.file_name}</span>
|
||||
<Download size={10} className="shrink-0" />
|
||||
</button>
|
||||
{canEditItemProcessing && onDeleteAttachment ? (
|
||||
<button
|
||||
type="button"
|
||||
title="Удалить файл"
|
||||
disabled={busyKey === `item-attachment-delete-${attachment.id}`}
|
||||
onClick={() => void onDeleteAttachment(requestId, item.id, attachment.id)}
|
||||
className="app-action-ghost inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full disabled:opacity-60"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="app-text-muted text-[11px]">Файлы не прикреплены</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<div>
|
||||
<button
|
||||
@ -926,6 +996,9 @@ export function ProcurementRequestDetailContent({
|
||||
onItemCommentDraftChange,
|
||||
onAddItemComment,
|
||||
onDeleteItemComment,
|
||||
onDownloadItemAttachment,
|
||||
onUploadItemAttachments,
|
||||
onDeleteItemAttachment,
|
||||
footer,
|
||||
}: ProcurementRequestDetailContentProps) {
|
||||
const canEditItemProcessing = Boolean(canProcessItems && onUpdateItem);
|
||||
@ -969,7 +1042,11 @@ export function ProcurementRequestDetailContent({
|
||||
<p className="app-text-wrap mt-2 whitespace-pre-line text-sm leading-6 text-[var(--foreground)]">
|
||||
{request.description || "—"}
|
||||
</p>
|
||||
<div className="mt-3 grid gap-2 text-xs sm:grid-cols-3">
|
||||
<div className="mt-3 grid gap-2 text-xs sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="app-surface-muted rounded-lg px-3 py-2">
|
||||
<p className="app-text-muted text-[11px] uppercase tracking-wide">Создана</p>
|
||||
<p className="mt-1 font-medium text-[var(--foreground)]">{formatDate(request.created_at) || "—"}</p>
|
||||
</div>
|
||||
<div className="app-surface-muted rounded-lg px-3 py-2">
|
||||
<p className="app-text-muted text-[11px] uppercase tracking-wide">Отправлена</p>
|
||||
<p className="mt-1 font-medium text-[var(--foreground)]">{formatDate(request.submitted_at) || "—"}</p>
|
||||
@ -1040,6 +1117,9 @@ export function ProcurementRequestDetailContent({
|
||||
onCommentDraftChange={onItemCommentDraftChange}
|
||||
onAddComment={onAddItemComment}
|
||||
onDeleteComment={onDeleteItemComment}
|
||||
onDownloadAttachment={onDownloadItemAttachment}
|
||||
onUploadAttachments={onUploadItemAttachments}
|
||||
onDeleteAttachment={onDeleteItemAttachment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -1102,6 +1182,8 @@ export function ProcurementRequestDetailContent({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ProcurementRequestHistory request={request} displayUserName={displayUserName} />
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ChevronRight, History, Loader2 } from "lucide-react";
|
||||
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { formatDateTime } from "@/lib/shared";
|
||||
import type { ProcurementRequest, ProcurementRequestActivity, User } from "@/types/api";
|
||||
|
||||
interface ProcurementRequestHistoryProps {
|
||||
request: ProcurementRequest;
|
||||
displayUserName: (
|
||||
person?: User | number | null,
|
||||
fallbackName?: string | null,
|
||||
fallbackEmail?: string | null,
|
||||
) => string;
|
||||
}
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
title: "название",
|
||||
description: "описание",
|
||||
department_id: "отдел-заказчик",
|
||||
processing_department_id: "отдел-исполнитель",
|
||||
urgency: "срочность",
|
||||
actual_cost: "фактическую стоимость",
|
||||
execution_status: "статус исполнения",
|
||||
ordered_quantity: "заказанное количество",
|
||||
received_quantity: "полученное количество",
|
||||
expected_delivery_dates: "ожидаемые даты",
|
||||
actual_unit_price: "фактическую цену",
|
||||
executor_comment: "комментарий исполнителя",
|
||||
links: "ссылки",
|
||||
};
|
||||
|
||||
function textMetadata(metadata: Record<string, unknown>, key: string): string {
|
||||
return typeof metadata[key] === "string" ? String(metadata[key]) : "";
|
||||
}
|
||||
|
||||
function getActivityDetail(activity: ProcurementRequestActivity): string {
|
||||
const metadata = activity.metadata || {};
|
||||
if (activity.action === "updated" || activity.action === "item_updated") {
|
||||
const fields = Array.isArray(metadata.fields) ? metadata.fields : [];
|
||||
const labels = fields.map((field) => fieldLabels[String(field)] || String(field));
|
||||
const itemName = textMetadata(metadata, "item_name");
|
||||
const prefix = itemName ? `${itemName}. ` : "";
|
||||
return labels.length > 0 ? `${prefix}Изменено: ${labels.join(", ")}` : itemName;
|
||||
}
|
||||
if (activity.action === "executor_reassigned") {
|
||||
const previous = textMetadata(metadata, "previous_executor");
|
||||
const next = textMetadata(metadata, "new_executor");
|
||||
return previous && next ? `${previous} -> ${next}` : next;
|
||||
}
|
||||
if (activity.action === "stage_approved" || activity.action === "stage_rejected") {
|
||||
const step = textMetadata(metadata, "step_name");
|
||||
const comment = textMetadata(metadata, "comment");
|
||||
return [step, comment].filter(Boolean).join(". ");
|
||||
}
|
||||
if (activity.action === "attachment_added" || activity.action === "attachment_removed") {
|
||||
const fileName = textMetadata(metadata, "file_name");
|
||||
const itemName = textMetadata(metadata, "item_name");
|
||||
return [itemName, fileName].filter(Boolean).join(": ");
|
||||
}
|
||||
if (activity.action === "comment_added" || activity.action === "comment_removed") {
|
||||
const itemName = textMetadata(metadata, "item_name");
|
||||
const comment = textMetadata(metadata, "comment_text");
|
||||
return [itemName, comment].filter(Boolean).join(": ");
|
||||
}
|
||||
if (activity.action === "cancelled") {
|
||||
return textMetadata(metadata, "reason");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function ActivityCard({
|
||||
activity,
|
||||
displayUserName,
|
||||
}: {
|
||||
activity: ProcurementRequestActivity;
|
||||
displayUserName: ProcurementRequestHistoryProps["displayUserName"];
|
||||
}) {
|
||||
const detail = getActivityDetail(activity);
|
||||
return (
|
||||
<article className="app-surface-muted rounded-xl border border-[var(--border-subtle)] px-3 py-2.5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="app-selected mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg">
|
||||
<History size={14} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">
|
||||
{activity.action_display || activity.action}
|
||||
</p>
|
||||
<span className="app-text-muted text-xs">{formatDateTime(activity.created_at)}</span>
|
||||
</div>
|
||||
<p className="app-text-muted mt-0.5 text-xs">
|
||||
{activity.actor ? displayUserName(activity.actor) : "Система"}
|
||||
</p>
|
||||
{detail ? (
|
||||
<p className="app-text-wrap mt-2 whitespace-pre-wrap text-xs text-[var(--foreground)]">
|
||||
{detail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProcurementRequestHistory({ request, displayUserName }: ProcurementRequestHistoryProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activities, setActivities] = useState<ProcurementRequestActivity[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const loadActivity = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await apiClient.getProcurementRequestActivity(request.id);
|
||||
setActivities(Array.isArray(result) ? result : []);
|
||||
setLoaded(true);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Не удалось загрузить историю");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [request.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void loadActivity();
|
||||
}, [open, request.updated_at, loadActivity]);
|
||||
|
||||
const toggle = () => {
|
||||
setOpen((current) => !current);
|
||||
};
|
||||
const count = loaded ? activities.length : request.activity_count || 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--border-subtle)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<History size={15} className="app-text-muted shrink-0" />
|
||||
<span className="text-sm font-medium text-[var(--foreground)]">История</span>
|
||||
{count > 0 ? (
|
||||
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">{count}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className={`app-text-muted shrink-0 transition-transform ${open ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="space-y-2 border-t border-[var(--border-subtle)] p-3">
|
||||
{loading && !loaded ? (
|
||||
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-4 text-center">
|
||||
<Loader2 size={18} className="mx-auto animate-spin text-sky-500" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="app-feedback-danger rounded-xl px-3 py-3 text-xs">{error}</div>
|
||||
) : activities.length > 0 ? (
|
||||
activities.map((activity) => (
|
||||
<ActivityCard key={activity.id} activity={activity} displayUserName={displayUserName} />
|
||||
))
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-4 text-center">
|
||||
<p className="app-text-muted text-xs">Истории действий пока нет</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -143,52 +143,34 @@ export function RequestListItem({
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{authorLink ? (
|
||||
<Link href={authorLink} className="group flex min-w-0 items-center gap-2">
|
||||
<RequestAvatar
|
||||
alt={authorName}
|
||||
fallback={authorFallback}
|
||||
size="lg"
|
||||
src={requestAuthor?.avatar}
|
||||
/>
|
||||
<span className="truncate text-sm font-medium text-[var(--foreground)] group-hover:text-[var(--accent-primary-strong)]">
|
||||
{authorName}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<RequestAvatar
|
||||
alt={authorName}
|
||||
fallback={authorFallback}
|
||||
size="lg"
|
||||
/>
|
||||
<span className="truncate text-sm font-medium text-[var(--foreground)]">{authorName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={() => onOpenDetails(request)} className="block w-full text-left">
|
||||
<h3
|
||||
className={`${rowOpen ? "app-text-wrap line-clamp-3" : "truncate"} text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]`}
|
||||
>
|
||||
<span className="app-text-muted">{typeLabel}:</span>{" "}
|
||||
<span className="text-[var(--foreground)]">{title}</span>
|
||||
</h3>
|
||||
</button>
|
||||
|
||||
<div className="app-text-muted mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span>
|
||||
Период: {request.date_from ? formatDate(request.date_from) : "—"}
|
||||
{request.date_to ? ` — ${formatDate(request.date_to)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-2 flex min-w-0 flex-wrap items-start gap-2">
|
||||
<div className="min-w-0 flex-[1_1_9rem]">
|
||||
{authorLink ? (
|
||||
<Link href={authorLink} className="group flex min-w-0 max-w-full items-center gap-2">
|
||||
<RequestAvatar
|
||||
alt={authorName}
|
||||
fallback={authorFallback}
|
||||
size="lg"
|
||||
src={requestAuthor?.avatar}
|
||||
/>
|
||||
<span className="app-text-wrap line-clamp-2 min-w-0 text-sm font-medium text-[var(--foreground)] group-hover:text-[var(--accent-primary-strong)]">
|
||||
{authorName}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex min-w-0 max-w-full items-center gap-2">
|
||||
<RequestAvatar
|
||||
alt={authorName}
|
||||
fallback={authorFallback}
|
||||
size="lg"
|
||||
/>
|
||||
<span className="app-text-wrap line-clamp-2 min-w-0 text-sm font-medium text-[var(--foreground)]">{authorName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<div ref={isMenuOpen ? menuRef : null} className="flex items-center justify-end gap-2">
|
||||
<div className="ml-auto shrink-0">
|
||||
<div ref={isMenuOpen ? menuRef : null} className="flex max-w-full items-center justify-end gap-2">
|
||||
<span className={`app-status-pill ${status.className}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
@ -270,6 +252,22 @@ export function RequestListItem({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={() => onOpenDetails(request)} className="block w-full text-left">
|
||||
<h3
|
||||
className={`app-text-wrap ${rowOpen ? "line-clamp-4" : "line-clamp-2"} text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]`}
|
||||
>
|
||||
<span className="app-text-muted">{typeLabel}:</span>{" "}
|
||||
<span className="text-[var(--foreground)]">{title}</span>
|
||||
</h3>
|
||||
</button>
|
||||
|
||||
<div className="app-text-muted app-text-wrap mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span>
|
||||
Период: {request.date_from ? formatDate(request.date_from) : "—"}
|
||||
{request.date_to ? ` — ${formatDate(request.date_to)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{summary ? (
|
||||
<p
|
||||
className={`${rowOpen ? "app-text-wrap line-clamp-10" : "app-text-wrap line-clamp-3"} mt-3 text-sm text-[var(--foreground)]`}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
let openModalCount = 0;
|
||||
const openModalStack: string[] = [];
|
||||
|
||||
export interface ModalProps {
|
||||
isOpen: boolean;
|
||||
@ -20,6 +21,8 @@ export interface ModalProps {
|
||||
noPadding?: boolean;
|
||||
/** Hide the built-in header (caller manages its own header inside children) */
|
||||
noHeader?: boolean;
|
||||
/** Visual level for a modal opened above another modal */
|
||||
stackLevel?: number;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
@ -35,36 +38,41 @@ export function Modal({
|
||||
className = "",
|
||||
noPadding = false,
|
||||
noHeader = false,
|
||||
stackLevel = 0,
|
||||
}: ModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
openModalCount += 1;
|
||||
openModalStack.push(titleId);
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
openModalCount = Math.max(0, openModalCount - 1);
|
||||
const stackIndex = openModalStack.lastIndexOf(titleId);
|
||||
if (stackIndex >= 0) openModalStack.splice(stackIndex, 1);
|
||||
if (openModalCount === 0) {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
};
|
||||
}, [isOpen]);
|
||||
}, [isOpen, titleId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !closeOnEsc) return;
|
||||
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (e.key === "Escape" && openModalStack.at(-1) === titleId) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEsc);
|
||||
return () => document.removeEventListener("keydown", handleEsc);
|
||||
}, [isOpen, closeOnEsc, onClose]);
|
||||
}, [isOpen, closeOnEsc, onClose, titleId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !contentRef.current) return;
|
||||
@ -81,7 +89,7 @@ export function Modal({
|
||||
firstElement?.focus();
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
if (e.key !== "Tab" || openModalStack.at(-1) !== titleId) return;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
@ -98,12 +106,16 @@ export function Modal({
|
||||
|
||||
document.addEventListener("keydown", handleTab);
|
||||
return () => document.removeEventListener("keydown", handleTab);
|
||||
}, [isOpen]);
|
||||
}, [isOpen, titleId]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (closeOnClickOutside && e.target === modalRef.current) {
|
||||
if (
|
||||
closeOnClickOutside &&
|
||||
e.target === modalRef.current &&
|
||||
openModalStack.at(-1) === titleId
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
@ -126,9 +138,10 @@ export function Modal({
|
||||
onClick={handleBackdropClick}
|
||||
data-overlay-root="true"
|
||||
className="app-overlay fixed inset-0 z-[100] flex items-center justify-center p-2 transition-opacity duration-200 opacity-100 sm:p-4"
|
||||
style={{ zIndex: 100 + stackLevel }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? "modal-title" : undefined}
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
@ -139,7 +152,7 @@ export function Modal({
|
||||
<div className="mb-3 flex shrink-0 items-center gap-2 px-4 pt-4 sm:mb-4 sm:gap-3 sm:px-6 sm:pt-6">
|
||||
{title && (
|
||||
<h3
|
||||
id="modal-title"
|
||||
id={titleId}
|
||||
className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--foreground)] sm:text-base lg:text-lg"
|
||||
title={title}
|
||||
>
|
||||
|
||||
@ -4,6 +4,7 @@ import React, { createContext, useContext, useEffect, useState, useCallback, use
|
||||
import apiClient from '@/lib/api';
|
||||
import wsManager from '@/lib/websocketManager';
|
||||
import { getVerbCategory } from '@/lib/verbTranslations';
|
||||
import { useUser } from '@/contexts/UserContext';
|
||||
|
||||
const NOTIFICATIONS_SYNC_INTERVAL_MS = 30000;
|
||||
const UNREAD_NOTIFICATIONS_PAGE_SIZE = 50;
|
||||
@ -52,6 +53,8 @@ interface NotificationsContextType {
|
||||
const NotificationsContext = createContext<NotificationsContextType | undefined>(undefined);
|
||||
|
||||
export function NotificationsProvider({ children }: { children: React.ReactNode }) {
|
||||
const { user } = useUser();
|
||||
const authenticatedUserId = user?.id ?? null;
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
@ -63,6 +66,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
);
|
||||
const fetchInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const summaryInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const knownNotificationIdsRef = useRef<Set<number>>(new Set());
|
||||
|
||||
const applyUnreadSummary = useCallback((summary: {
|
||||
total?: number;
|
||||
@ -114,6 +118,11 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
});
|
||||
const notifs = data.notifications || data.results || data;
|
||||
const notificationsArray = Array.isArray(notifs) ? notifs : [];
|
||||
notificationsArray.forEach((notification) => {
|
||||
if (typeof notification.id === 'number') {
|
||||
knownNotificationIdsRef.current.add(notification.id);
|
||||
}
|
||||
});
|
||||
setNotifications(notificationsArray);
|
||||
setUnreadCount(data.unread_count ?? notificationsArray.length);
|
||||
setError(null);
|
||||
@ -157,13 +166,24 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
|
||||
// Загрузка начальных данных
|
||||
useEffect(() => {
|
||||
if (!authenticatedUserId) {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
setUnreadCategoryCounts({});
|
||||
setUnreadProcurementRequestCounts({});
|
||||
knownNotificationIdsRef.current.clear();
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
void syncUnreadNotifications(true);
|
||||
void syncUnreadSummary();
|
||||
}, [syncUnreadNotifications, syncUnreadSummary]);
|
||||
}, [authenticatedUserId, syncUnreadNotifications, syncUnreadSummary]);
|
||||
|
||||
// ЕДИНСТВЕННЫЙ WebSocket через singleton manager
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !localStorage.getItem('access_token')) {
|
||||
if (!authenticatedUserId || typeof window === 'undefined' || !localStorage.getItem('access_token')) {
|
||||
console.log('[NotificationsContext] WebSocket disabled: not in browser or not authenticated');
|
||||
return;
|
||||
}
|
||||
@ -178,12 +198,13 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
const incomingNotification = data.notification;
|
||||
|
||||
if (data.type === 'notification' && incomingNotification) {
|
||||
setNotifications(prev => {
|
||||
if (prev.some(n => n.id === incomingNotification.id)) {
|
||||
return prev;
|
||||
}
|
||||
return [incomingNotification, ...prev];
|
||||
});
|
||||
if (knownNotificationIdsRef.current.has(incomingNotification.id)) {
|
||||
void syncUnreadSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
knownNotificationIdsRef.current.add(incomingNotification.id);
|
||||
setNotifications(prev => [incomingNotification, ...prev]);
|
||||
|
||||
const isRead = incomingNotification.is_read ?? !incomingNotification.unread;
|
||||
if (!isRead) {
|
||||
@ -205,6 +226,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
}
|
||||
}
|
||||
}
|
||||
void syncUnreadSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -251,11 +273,11 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
unsubscribeMessages();
|
||||
unsubscribeStatus();
|
||||
};
|
||||
}, [syncUnreadNotifications, syncUnreadSummary]);
|
||||
}, [authenticatedUserId, syncUnreadNotifications, syncUnreadSummary]);
|
||||
|
||||
// Регулярная сверка с API нужна даже при живом WebSocket: часть событий может не дойти по сокету.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !localStorage.getItem('access_token')) {
|
||||
if (!authenticatedUserId || typeof window === 'undefined' || !localStorage.getItem('access_token')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -282,16 +304,16 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [syncUnreadNotifications, syncUnreadSummary]);
|
||||
}, [authenticatedUserId, syncUnreadNotifications, syncUnreadSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWsConnected) {
|
||||
if (!authenticatedUserId || !isWsConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
void syncUnreadSummary();
|
||||
void syncUnreadNotifications(false);
|
||||
}, [isWsConnected, syncUnreadNotifications, syncUnreadSummary]);
|
||||
}, [authenticatedUserId, isWsConnected, syncUnreadNotifications, syncUnreadSummary]);
|
||||
|
||||
const markAsRead = useCallback(async (id: number) => {
|
||||
await apiClient.markNotificationAsRead(id);
|
||||
|
||||
@ -12,6 +12,7 @@ import type {
|
||||
ProcurementApprovalStepSelection,
|
||||
ProcurementComment,
|
||||
ProcurementItem,
|
||||
ProcurementItemAttachment,
|
||||
ProcurementItemComment,
|
||||
ProcurementRequest,
|
||||
ProcurementStatus,
|
||||
@ -29,6 +30,9 @@ type ItemDraft = {
|
||||
supplier_info: string;
|
||||
links: string[];
|
||||
initial_comment: string;
|
||||
attachments: ProcurementItemAttachment[];
|
||||
pending_files: File[];
|
||||
removed_attachment_ids: number[];
|
||||
};
|
||||
|
||||
const emptyItem: ItemDraft = {
|
||||
@ -40,6 +44,9 @@ const emptyItem: ItemDraft = {
|
||||
supplier_info: "",
|
||||
links: [],
|
||||
initial_comment: "",
|
||||
attachments: [],
|
||||
pending_files: [],
|
||||
removed_attachment_ids: [],
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
@ -575,6 +582,9 @@ export function useProcurementPage(user: User | null) {
|
||||
supplier_info: item.supplier_info || "",
|
||||
links: toLinkRows(item.links),
|
||||
initial_comment: "",
|
||||
attachments: item.attachments || [],
|
||||
pending_files: [],
|
||||
removed_attachment_ids: [],
|
||||
}))
|
||||
: [{ ...emptyItem }],
|
||||
});
|
||||
@ -714,9 +724,10 @@ export function useProcurementPage(user: User | null) {
|
||||
})),
|
||||
};
|
||||
|
||||
let savedRequest: ProcurementRequest;
|
||||
if (modalMode === "create") {
|
||||
await apiClient.createProcurementRequest(payload) as ProcurementRequest;
|
||||
toast.success("Заявка создана и направлена в отдел.");
|
||||
const created = await apiClient.createProcurementRequest(payload) as ProcurementRequest;
|
||||
savedRequest = await apiClient.getProcurementRequest(created.id) as ProcurementRequest;
|
||||
setCreateOpen(false);
|
||||
} else if (editingId) {
|
||||
await apiClient.updateProcurementRequest(editingId, {
|
||||
@ -726,10 +737,43 @@ export function useProcurementPage(user: User | null) {
|
||||
processing_department: payload.processing_department,
|
||||
items: payload.items,
|
||||
});
|
||||
toast.success("Заявка обновлена.");
|
||||
savedRequest = await apiClient.getProcurementRequest(editingId) as ProcurementRequest;
|
||||
setEditingId(null);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIds = new Set(
|
||||
validItems
|
||||
.map((item) => item.id)
|
||||
.filter((id): id is number => typeof id === "number"),
|
||||
);
|
||||
const createdItems = (savedRequest.items || []).filter((item) => !existingIds.has(item.id));
|
||||
let createdItemIndex = 0;
|
||||
|
||||
for (const item of validItems) {
|
||||
const itemId = item.id ?? createdItems[createdItemIndex++]?.id;
|
||||
if (!itemId) continue;
|
||||
|
||||
if (item.removed_attachment_ids.length > 0) {
|
||||
await Promise.all(
|
||||
item.removed_attachment_ids.map((attachmentId) => (
|
||||
apiClient.deleteProcurementItemAttachment(itemId, attachmentId)
|
||||
)),
|
||||
);
|
||||
}
|
||||
if (item.pending_files.length > 0) {
|
||||
await apiClient.uploadProcurementItemAttachments(itemId, item.pending_files);
|
||||
}
|
||||
}
|
||||
|
||||
await refreshOne(savedRequest.id);
|
||||
toast.success(
|
||||
modalMode === "create"
|
||||
? "Заявка создана и направлена в отдел."
|
||||
: "Заявка обновлена.",
|
||||
);
|
||||
|
||||
resetForm();
|
||||
await refreshCurrentView();
|
||||
if (editingId) {
|
||||
@ -740,7 +784,7 @@ export function useProcurementPage(user: User | null) {
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [editingId, form, markRequestNotificationsRead, modalMode, refreshCurrentView, resetForm]);
|
||||
}, [editingId, form, markRequestNotificationsRead, modalMode, refreshCurrentView, refreshOne, resetForm]);
|
||||
|
||||
const handleSave = useCallback(() => saveRequest(), [saveRequest]);
|
||||
|
||||
@ -1205,6 +1249,64 @@ export function useProcurementPage(user: User | null) {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleDownloadItemAttachment = useCallback(async (
|
||||
itemId: number,
|
||||
attachment: ProcurementItemAttachment,
|
||||
) => {
|
||||
try {
|
||||
const { blob, filename } = await apiClient.downloadProcurementItemAttachment(itemId, attachment);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (downloadError) {
|
||||
setActionError(getReadableError(downloadError, "Не удалось скачать файл"));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUploadItemAttachments = useCallback(async (
|
||||
requestId: number,
|
||||
itemId: number,
|
||||
files: File[],
|
||||
) => {
|
||||
if (files.length === 0) return false;
|
||||
try {
|
||||
setBusyKey(`item-attachments-${itemId}`);
|
||||
await apiClient.uploadProcurementItemAttachments(itemId, files);
|
||||
await refreshOne(requestId);
|
||||
toast.success(files.length > 1 ? "Файлы добавлены." : "Файл добавлен.");
|
||||
return true;
|
||||
} catch (uploadError) {
|
||||
setActionError(getReadableError(uploadError, "Не удалось добавить файл"));
|
||||
return false;
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [refreshOne]);
|
||||
|
||||
const handleDeleteItemAttachment = useCallback(async (
|
||||
requestId: number,
|
||||
itemId: number,
|
||||
attachmentId: number,
|
||||
) => {
|
||||
try {
|
||||
setBusyKey(`item-attachment-delete-${attachmentId}`);
|
||||
await apiClient.deleteProcurementItemAttachment(itemId, attachmentId);
|
||||
await refreshOne(requestId);
|
||||
toast.success("Файл удалён.");
|
||||
return true;
|
||||
} catch (deleteError) {
|
||||
setActionError(getReadableError(deleteError, "Не удалось удалить файл"));
|
||||
return false;
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [refreshOne]);
|
||||
|
||||
const activeFilterCount = [
|
||||
statusFilter.length > 0,
|
||||
urgencyFilter,
|
||||
@ -1315,6 +1417,9 @@ export function useProcurementPage(user: User | null) {
|
||||
addItemRow,
|
||||
removeItemRow,
|
||||
updateItemRow,
|
||||
handleDownloadItemAttachment,
|
||||
handleUploadItemAttachments,
|
||||
handleDeleteItemAttachment,
|
||||
activeFilterCount,
|
||||
isFinal,
|
||||
resolveUserId,
|
||||
|
||||
@ -388,11 +388,6 @@ export function useSettingsPage() {
|
||||
toast.error("Можно загрузить только изображение");
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error("Максимальный размер файла 5 МБ");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== "string") {
|
||||
|
||||
@ -255,11 +255,6 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert("Файл слишком большой. Максимальный размер: 5MB");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
alert("Можно загружать только изображения");
|
||||
return;
|
||||
|
||||
@ -8,7 +8,7 @@ import type {
|
||||
} from '@/types/api';
|
||||
|
||||
type CalendarScopeParam = { scope?: "all" };
|
||||
type CalendarQueryParams = { start?: string; end?: string; calendar?: number; scope?: "all" };
|
||||
type CalendarQueryParams = { start?: string; end?: string; calendar?: number; scope?: "all"; search?: string; page_size?: number };
|
||||
type CalendarListResponse = PaginatedResponse<Calendar> | Calendar[];
|
||||
type CalendarEventsResponse = PaginatedResponse<CalendarEvent> | CalendarEvent[];
|
||||
type CalendarRulesParams = Record<string, unknown>;
|
||||
@ -21,6 +21,8 @@ export function createCalendarApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
if (params?.end) qp.append('end', params.end);
|
||||
if (params?.calendar) qp.append('calendar', params.calendar.toString());
|
||||
if (params?.scope) qp.append('scope', params.scope);
|
||||
if (params?.search) qp.append('search', params.search);
|
||||
if (params?.page_size) qp.append('page_size', params.page_size.toString());
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/schedule/events/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
|
||||
@ -46,11 +46,12 @@ async function fetchDownload(
|
||||
|
||||
export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, requestRaw?: RawRequestFn) {
|
||||
return {
|
||||
getDocuments: (params?: { search?: string; type?: string; status?: string; page?: number; page_size?: number; limit?: number; folder_id?: number; is_regulation?: boolean }) => {
|
||||
getDocuments: (params?: { search?: string; type?: string; status?: string; scope?: string; page?: number; page_size?: number; limit?: number; folder_id?: number; is_regulation?: boolean }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.search) qp.append('search', params.search);
|
||||
if (params?.type) qp.append('type', params.type);
|
||||
if (params?.status) qp.append('status', params.status);
|
||||
if (params?.scope) qp.append('scope', params.scope);
|
||||
if (params?.page) qp.append('page', params.page.toString());
|
||||
const pageSize = params?.page_size ?? params?.limit;
|
||||
if (pageSize) qp.append('page_size', pageSize.toString());
|
||||
@ -60,7 +61,7 @@ export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, re
|
||||
return request(`/api/v1/documents/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getDocument: (id: number) => request(`/api/v1/documents/${id}/`),
|
||||
createDocument: (data: { title?: string; description?: string; file?: File | Blob; extracted_text?: string; sent_to_all?: boolean; is_regulation?: boolean; recipient_ids?: number[]; department_ids?: number[]; folder_id?: number | null; acknowledgement_required?: boolean; tag_ids?: number[] }) => {
|
||||
createDocument: (data: { title?: string; description?: string; file?: File | Blob; extracted_text?: string; sent_to_all?: boolean; is_regulation?: boolean; recipient_ids?: number[]; department_ids?: number[]; folder_id?: number | null; acknowledgement_required?: boolean; acknowledgement_for_all?: boolean; acknowledgement_recipient_ids?: number[]; acknowledgement_department_ids?: number[]; tag_ids?: number[] }) => {
|
||||
const fd = new FormData();
|
||||
if (data.title?.trim()) fd.append('title', data.title.trim());
|
||||
if (data.description) fd.append('description', data.description);
|
||||
@ -69,13 +70,16 @@ export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, re
|
||||
fd.append('sent_to_all', String(data.sent_to_all ?? true));
|
||||
fd.append('is_regulation', String(data.is_regulation ?? false));
|
||||
if (data.acknowledgement_required !== undefined) fd.append('acknowledgement_required', String(data.acknowledgement_required));
|
||||
data.recipient_ids?.forEach(id => fd.append('recipient_ids', String(id)));
|
||||
data.department_ids?.forEach(id => fd.append('department_ids', String(id)));
|
||||
if (data.acknowledgement_for_all !== undefined) fd.append('acknowledgement_for_all', String(data.acknowledgement_for_all));
|
||||
if (data.recipient_ids !== undefined) fd.append('recipient_ids', JSON.stringify(data.recipient_ids));
|
||||
if (data.department_ids !== undefined) fd.append('department_ids', JSON.stringify(data.department_ids));
|
||||
if (data.acknowledgement_recipient_ids !== undefined) fd.append('acknowledgement_recipient_ids', JSON.stringify(data.acknowledgement_recipient_ids));
|
||||
if (data.acknowledgement_department_ids !== undefined) fd.append('acknowledgement_department_ids', JSON.stringify(data.acknowledgement_department_ids));
|
||||
data.tag_ids?.forEach(id => fd.append('tag_ids', String(id)));
|
||||
if (data.file) fd.append('file', data.file);
|
||||
return request('/api/v1/documents/', { method: 'POST', body: fd });
|
||||
},
|
||||
updateDocument: (id: number, data: { title?: string; description?: string; extracted_text?: string; file?: File; tag_ids?: number[]; folder?: number | null; is_regulation?: boolean; sent_to_all?: boolean; acknowledgement_required?: boolean; recipient_ids?: number[]; department_ids?: number[] }) => {
|
||||
updateDocument: (id: number, data: { title?: string; description?: string; extracted_text?: string; file?: File; tag_ids?: number[]; folder?: number | null; is_regulation?: boolean; sent_to_all?: boolean; acknowledgement_required?: boolean; acknowledgement_for_all?: boolean; recipient_ids?: number[]; department_ids?: number[]; acknowledgement_recipient_ids?: number[]; acknowledgement_department_ids?: number[] }) => {
|
||||
const fd = new FormData();
|
||||
if (data.title !== undefined) fd.append('title', data.title);
|
||||
if (data.description !== undefined) fd.append('description', data.description);
|
||||
@ -85,8 +89,11 @@ export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, re
|
||||
if (data.is_regulation !== undefined) fd.append('is_regulation', String(data.is_regulation));
|
||||
if (data.sent_to_all !== undefined) fd.append('sent_to_all', String(data.sent_to_all));
|
||||
if (data.acknowledgement_required !== undefined) fd.append('acknowledgement_required', String(data.acknowledgement_required));
|
||||
data.department_ids?.forEach(departmentId => fd.append('department_ids', String(departmentId)));
|
||||
data.recipient_ids?.forEach(recipientId => fd.append('recipient_ids', String(recipientId)));
|
||||
if (data.acknowledgement_for_all !== undefined) fd.append('acknowledgement_for_all', String(data.acknowledgement_for_all));
|
||||
if (data.department_ids !== undefined) fd.append('department_ids', JSON.stringify(data.department_ids));
|
||||
if (data.recipient_ids !== undefined) fd.append('recipient_ids', JSON.stringify(data.recipient_ids));
|
||||
if (data.acknowledgement_department_ids !== undefined) fd.append('acknowledgement_department_ids', JSON.stringify(data.acknowledgement_department_ids));
|
||||
if (data.acknowledgement_recipient_ids !== undefined) fd.append('acknowledgement_recipient_ids', JSON.stringify(data.acknowledgement_recipient_ids));
|
||||
data.tag_ids?.forEach(tagId => fd.append('tag_ids', String(tagId)));
|
||||
return request(`/api/v1/documents/${id}/`, { method: 'PATCH', body: fd });
|
||||
},
|
||||
@ -138,11 +145,11 @@ export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, re
|
||||
getFolderChildren: (id: number) => request(`/api/v1/folders/${id}/children/`),
|
||||
getFolderDocuments: (id: number) => request(`/api/v1/folders/${id}/documents/`),
|
||||
// Comments
|
||||
getDocumentComments: (documentId: number) => request(`/api/v1/document-comments/?document=${documentId}`),
|
||||
getDocumentComments: (documentId: number) => request(`/api/v1/documents/${documentId}/comments/`),
|
||||
createDocumentComment: (data: { document: number; text: string; parent?: number }) =>
|
||||
request('/api/v1/document-comments/', { method: 'POST', body: JSON.stringify({ document_id: data.document, text: data.text, parent_id: data.parent }) }),
|
||||
updateDocumentComment: (id: number, text: string) => request(`/api/v1/document-comments/${id}/`, { method: 'PATCH', body: JSON.stringify({ text }) }),
|
||||
deleteDocumentComment: (id: number): Promise<void> => request(`/api/v1/document-comments/${id}/`, { method: 'DELETE' }),
|
||||
request(`/api/v1/documents/${data.document}/comments/`, { method: 'POST', body: JSON.stringify({ text: data.text, parent_id: data.parent }) }),
|
||||
updateDocumentComment: (documentId: number, id: number, text: string) => request(`/api/v1/documents/${documentId}/comments/${id}/`, { method: 'PATCH', body: JSON.stringify({ text }) }),
|
||||
deleteDocumentComment: (documentId: number, id: number): Promise<void> => request(`/api/v1/documents/${documentId}/comments/${id}/`, { method: 'DELETE' }),
|
||||
getCommentReplies: (commentId: number) => request(`/api/v1/document-comments/${commentId}/replies/`),
|
||||
// Tags
|
||||
getDocumentTags: (params?: { page?: number; page_size?: number; limit?: number }) => {
|
||||
|
||||
@ -32,10 +32,10 @@ export const apiClient = Object.assign(base, {
|
||||
...createFeedApi(req, tok),
|
||||
...createRequestsApi(req, tok),
|
||||
...createEquipmentApi(req, tok),
|
||||
...createProcurementApi(req),
|
||||
...createProcurementApi(req, raw),
|
||||
...createAttendanceApi(req, tok),
|
||||
...createGuestsApi(req),
|
||||
...createTasksApi(req),
|
||||
...createTasksApi(req, raw),
|
||||
});
|
||||
|
||||
export default apiClient;
|
||||
|
||||
@ -1,11 +1,22 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { ProcurementApprovalOptions, ProcurementApprovalStepSelection, ProcurementCreateOptions } from '@/types/api';
|
||||
import { buildQuery, type RequestFn } from './utils';
|
||||
import type { ProcurementApprovalOptions, ProcurementApprovalStepSelection, ProcurementCreateOptions, ProcurementItemAttachment, ProcurementRequestActivity } from '@/types/api';
|
||||
import { buildQuery, type RawRequestFn, type RequestFn } from './utils';
|
||||
|
||||
export function createProcurementApi(request: RequestFn) {
|
||||
function parseDownloadFilename(response: Response, fallback: string): string {
|
||||
const disposition = response.headers.get('content-disposition') || '';
|
||||
const encodedMatch = disposition.match(/filename\*=UTF-8''([^;]+)/);
|
||||
const plainMatch = disposition.match(/filename="?([^";]+)"?/);
|
||||
return encodedMatch?.[1]
|
||||
? decodeURIComponent(encodedMatch[1])
|
||||
: plainMatch?.[1] || fallback;
|
||||
}
|
||||
|
||||
export function createProcurementApi(request: RequestFn, requestRaw: RawRequestFn) {
|
||||
return {
|
||||
getProcurementRequests: (params?: Record<string, string | number>) => request(`/api/v1/procurement/requests/${buildQuery(params)}`),
|
||||
getProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/`),
|
||||
getProcurementRequestActivity: (id: number): Promise<ProcurementRequestActivity[]> =>
|
||||
request(`/api/v1/procurement/requests/${id}/activity/`),
|
||||
getProcurementRequestCreateOptions: (): Promise<ProcurementCreateOptions> => request('/api/v1/procurement/requests/create-options/'),
|
||||
createProcurementRequest: (data: Record<string, any>) => request('/api/v1/procurement/requests/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
updateProcurementRequest: (id: number, data: Record<string, any>) => request(`/api/v1/procurement/requests/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
@ -63,6 +74,30 @@ export function createProcurementApi(request: RequestFn) {
|
||||
request(`/api/v1/procurement/items/${itemId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }),
|
||||
deleteProcurementItemComment: (itemId: number, commentId: number): Promise<void> =>
|
||||
request(`/api/v1/procurement/items/${itemId}/comments/${commentId}/`, { method: 'DELETE' }),
|
||||
getProcurementItemAttachments: (itemId: number): Promise<ProcurementItemAttachment[]> =>
|
||||
request(`/api/v1/procurement/items/${itemId}/attachments/`),
|
||||
uploadProcurementItemAttachments: (itemId: number, files: File[]): Promise<ProcurementItemAttachment[]> => {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append('files', file));
|
||||
return request(`/api/v1/procurement/items/${itemId}/attachments/`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
},
|
||||
downloadProcurementItemAttachment: async (
|
||||
itemId: number,
|
||||
attachment: ProcurementItemAttachment,
|
||||
): Promise<{ blob: Blob; filename: string }> => {
|
||||
const response = await requestRaw(
|
||||
attachment.download_url || `/api/v1/procurement/items/${itemId}/attachments/${attachment.id}/`,
|
||||
);
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
filename: parseDownloadFilename(response, attachment.file_name),
|
||||
};
|
||||
},
|
||||
deleteProcurementItemAttachment: (itemId: number, attachmentId: number): Promise<void> =>
|
||||
request(`/api/v1/procurement/items/${itemId}/attachments/${attachmentId}/`, { method: 'DELETE' }),
|
||||
// Suppliers
|
||||
getProcurementSuppliers: (params?: Record<string, string | number>) => request(`/api/v1/procurement/suppliers/${buildQuery(params)}`),
|
||||
createProcurementSupplier: (data: Record<string, any>) => request('/api/v1/procurement/suppliers/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
|
||||
@ -4,6 +4,8 @@ import type {
|
||||
TaskCard,
|
||||
TaskColumn,
|
||||
TaskActivity,
|
||||
TaskAttachment,
|
||||
TaskChecklistItem,
|
||||
TaskComment,
|
||||
TaskLabel,
|
||||
TaskLinkedAttendanceRecord,
|
||||
@ -16,10 +18,20 @@ import type {
|
||||
TaskLinkedPost,
|
||||
TaskLinkedProcurementRequest,
|
||||
TaskLinkedRequest,
|
||||
TaskExternalLink,
|
||||
} from "@/types/api";
|
||||
import { buildQuery, type RequestFn } from "./utils";
|
||||
import { buildQuery, type RawRequestFn, type RequestFn } from "./utils";
|
||||
|
||||
export function createTasksApi(request: RequestFn) {
|
||||
function parseDownloadFilename(response: Response, fallback: string): string {
|
||||
const disposition = response.headers.get("content-disposition") || "";
|
||||
const encodedMatch = disposition.match(/filename\*=UTF-8''([^;]+)/);
|
||||
const plainMatch = disposition.match(/filename="?([^";]+)"?/);
|
||||
return encodedMatch?.[1]
|
||||
? decodeURIComponent(encodedMatch[1])
|
||||
: plainMatch?.[1] || fallback;
|
||||
}
|
||||
|
||||
export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
|
||||
return {
|
||||
getTaskBoards: (params?: Record<string, string | number>) =>
|
||||
request(`/api/v1/tasks/boards/${buildQuery(params)}`),
|
||||
@ -69,8 +81,65 @@ export function createTasksApi(request: RequestFn) {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
completeTask: (id: number): Promise<TaskCard> =>
|
||||
request(`/api/v1/tasks/${id}/complete/`, {
|
||||
method: "POST",
|
||||
}),
|
||||
claimTask: (id: number): Promise<TaskCard> =>
|
||||
request(`/api/v1/tasks/${id}/claim/`, {
|
||||
method: "POST",
|
||||
}),
|
||||
getTaskActivity: (id: number): Promise<TaskActivity[]> =>
|
||||
request(`/api/v1/tasks/${id}/activity/`),
|
||||
getTaskAttachments: (id: number): Promise<TaskAttachment[]> =>
|
||||
request(`/api/v1/tasks/${id}/attachments/`),
|
||||
uploadTaskAttachments: (id: number, files: File[]): Promise<TaskAttachment[]> => {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append("files", file));
|
||||
return request(`/api/v1/tasks/${id}/attachments/`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
},
|
||||
downloadTaskAttachment: async (
|
||||
taskId: number,
|
||||
attachment: TaskAttachment,
|
||||
): Promise<{ blob: Blob; filename: string }> => {
|
||||
const response = await requestRaw(
|
||||
attachment.download_url || `/api/v1/tasks/${taskId}/attachments/${attachment.id}/download/`,
|
||||
);
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
filename: parseDownloadFilename(response, attachment.file_name),
|
||||
};
|
||||
},
|
||||
deleteTaskAttachment: (taskId: number, attachmentId: number): Promise<void> =>
|
||||
request(`/api/v1/tasks/${taskId}/attachments/${attachmentId}/`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getTaskChecklist: (id: number): Promise<TaskChecklistItem[]> =>
|
||||
request(`/api/v1/tasks/${id}/checklist/`),
|
||||
addTaskChecklistItem: (
|
||||
id: number,
|
||||
data: { title: string; position?: number; is_completed?: boolean },
|
||||
): Promise<TaskChecklistItem> =>
|
||||
request(`/api/v1/tasks/${id}/checklist/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
updateTaskChecklistItem: (
|
||||
id: number,
|
||||
itemId: number,
|
||||
data: { title?: string; position?: number; is_completed?: boolean },
|
||||
): Promise<TaskChecklistItem> =>
|
||||
request(`/api/v1/tasks/${id}/checklist/${itemId}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteTaskChecklistItem: (id: number, itemId: number): Promise<void> =>
|
||||
request(`/api/v1/tasks/${id}/checklist/${itemId}/`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getTaskComments: (id: number): Promise<TaskComment[]> =>
|
||||
request(`/api/v1/tasks/${id}/comments/`),
|
||||
addTaskComment: (id: number, text: string): Promise<TaskComment> =>
|
||||
@ -78,6 +147,11 @@ export function createTasksApi(request: RequestFn) {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text }),
|
||||
}),
|
||||
updateTaskComment: (id: number, commentId: number, text: string): Promise<TaskComment> =>
|
||||
request(`/api/v1/tasks/${id}/comments/${commentId}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ text }),
|
||||
}),
|
||||
deleteTaskComment: (id: number, commentId: number): Promise<void> =>
|
||||
request(`/api/v1/tasks/${id}/comments/${commentId}/`, {
|
||||
method: "DELETE",
|
||||
@ -210,6 +284,20 @@ export function createTasksApi(request: RequestFn) {
|
||||
request(`/api/v1/tasks/${id}/linked-events/${linkId}/`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getTaskExternalLinks: (id: number): Promise<TaskExternalLink[]> =>
|
||||
request(`/api/v1/tasks/${id}/external-links/`),
|
||||
addTaskExternalLink: (
|
||||
id: number,
|
||||
data: { url: string; title?: string },
|
||||
): Promise<TaskExternalLink> =>
|
||||
request(`/api/v1/tasks/${id}/external-links/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteTaskExternalLink: (id: number, linkId: number): Promise<void> =>
|
||||
request(`/api/v1/tasks/${id}/external-links/${linkId}/`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
deleteTask: (id: number): Promise<void> =>
|
||||
request(`/api/v1/tasks/${id}/`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
12
frontend/src/lib/document-preview.test.js
Normal file
12
frontend/src/lib/document-preview.test.js
Normal file
@ -0,0 +1,12 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { canPreviewDocument, getDocumentFileExtension } from "./document-preview.ts";
|
||||
|
||||
test("document preview capabilities stay consistent across document surfaces", () => {
|
||||
assert.equal(getDocumentFileExtension("policy.final.PDF?download=1"), "pdf");
|
||||
assert.equal(canPreviewDocument("policy.pdf"), true);
|
||||
assert.equal(canPreviewDocument("instructions.docx"), true);
|
||||
assert.equal(canPreviewDocument("README"), true);
|
||||
assert.equal(canPreviewDocument("archive.zip"), false);
|
||||
});
|
||||
100
frontend/src/lib/document-preview.ts
Normal file
100
frontend/src/lib/document-preview.ts
Normal file
@ -0,0 +1,100 @@
|
||||
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",
|
||||
]);
|
||||
|
||||
export function getDocumentFileExtension(fileName?: string | null): string {
|
||||
const cleanName = (fileName || "").toLowerCase().split("?")[0].split("#")[0];
|
||||
return cleanName.match(/\.([^.]+)$/)?.[1] || "";
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
export interface ImageCompressOptions {
|
||||
maxSizeMB?: number;
|
||||
maxWidthOrHeight?: number;
|
||||
quality?: number;
|
||||
}
|
||||
@ -28,7 +27,6 @@ export async function compressImage(
|
||||
options: ImageCompressOptions = {}
|
||||
): Promise<Blob> {
|
||||
const {
|
||||
maxSizeMB = 10,
|
||||
maxWidthOrHeight = 2048,
|
||||
quality = 0.85
|
||||
} = options;
|
||||
@ -62,21 +60,7 @@ export async function compressImage(
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
// Проверяем размер
|
||||
const sizeMB = blob.size / (1024 * 1024);
|
||||
if (sizeMB > maxSizeMB) {
|
||||
// Если еще слишком большой, рекурсивно сжимаем с меньшим quality
|
||||
const newQuality = quality * 0.8;
|
||||
if (newQuality < 0.1) {
|
||||
reject(new Error('Не удалось сжать изображение до требуемого размера'));
|
||||
} else {
|
||||
compressImage(file, { ...options, quality: newQuality })
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
}
|
||||
} else {
|
||||
resolve(blob);
|
||||
}
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Ошибка создания Blob'));
|
||||
}
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
import { performOCR, isOCRSupported } from './ocr';
|
||||
import { extractPDFText, generatePDFThumbnail, getPDFInfo } from './pdf';
|
||||
import { extractPDFText, generatePDFThumbnail } from './pdf';
|
||||
import { extractDOCXText, isDOCXFile } from './docx';
|
||||
import { compressImage, generateImageThumbnail, isImageFile } from './image';
|
||||
import { generateImageThumbnail, isImageFile } from './image';
|
||||
|
||||
export interface ProcessingResult {
|
||||
extractedText: string;
|
||||
@ -24,17 +24,15 @@ export interface ProcessingResult {
|
||||
}
|
||||
|
||||
export interface ProcessingProgress {
|
||||
stage: 'compressing' | 'ocr' | 'extracting_text' | 'generating_thumbnail' | 'complete';
|
||||
stage: 'ocr' | 'extracting_text' | 'generating_thumbnail' | 'complete';
|
||||
progress: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ProcessDocumentOptions {
|
||||
enableOCR?: boolean;
|
||||
enableCompression?: boolean;
|
||||
enableTextExtraction?: boolean;
|
||||
enableThumbnail?: boolean;
|
||||
maxImageSizeMB?: number;
|
||||
onProgress?: (progress: ProcessingProgress) => void;
|
||||
}
|
||||
|
||||
@ -42,7 +40,6 @@ export interface ProcessDocumentOptions {
|
||||
* Интеллектуально обрабатывает документ перед загрузкой
|
||||
*
|
||||
* Автоматически определяет тип файла и выполняет:
|
||||
* - Сжатие изображений
|
||||
* - OCR для изображений
|
||||
* - Извлечение текста из PDF/DOCX
|
||||
* - Генерацию thumbnails
|
||||
@ -57,42 +54,18 @@ export async function processDocument(
|
||||
): Promise<ProcessingResult> {
|
||||
const {
|
||||
enableOCR = true,
|
||||
enableCompression = true,
|
||||
enableTextExtraction = true,
|
||||
enableThumbnail = true,
|
||||
maxImageSizeMB = 10,
|
||||
onProgress
|
||||
} = options;
|
||||
|
||||
const startTime = Date.now();
|
||||
let extractedText = '';
|
||||
let thumbnail: string | undefined;
|
||||
let processedFile: File | Blob | undefined;
|
||||
let ocrApplied = false;
|
||||
let compressed = false;
|
||||
|
||||
try {
|
||||
// 1. Сжатие изображений
|
||||
if (enableCompression && isImageFile(file)) {
|
||||
onProgress?.({
|
||||
stage: 'compressing',
|
||||
progress: 10,
|
||||
message: 'Сжатие изображения...'
|
||||
});
|
||||
|
||||
const sizeMB = file.size / (1024 * 1024);
|
||||
if (sizeMB > maxImageSizeMB) {
|
||||
processedFile = await compressImage(file, {
|
||||
maxSizeMB: maxImageSizeMB,
|
||||
quality: 0.85
|
||||
});
|
||||
compressed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const fileToProcess = processedFile || file;
|
||||
|
||||
// 2. Извлечение текста
|
||||
// 1. Извлечение текста
|
||||
if (enableTextExtraction) {
|
||||
onProgress?.({
|
||||
stage: 'extracting_text',
|
||||
@ -136,7 +109,7 @@ export async function processDocument(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Генерация thumbnail
|
||||
// 2. Генерация thumbnail
|
||||
if (enableThumbnail) {
|
||||
onProgress?.({
|
||||
stage: 'generating_thumbnail',
|
||||
@ -162,15 +135,12 @@ export async function processDocument(
|
||||
return {
|
||||
extractedText,
|
||||
thumbnail,
|
||||
processedFile,
|
||||
metadata: {
|
||||
originalSize: file.size,
|
||||
processedSize: processedFile?.size,
|
||||
mimeType: file.type,
|
||||
hasText: extractedText.length > 0,
|
||||
processingTime,
|
||||
ocrApplied,
|
||||
compressed
|
||||
ocrApplied
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
25
frontend/src/lib/navigation-notifications.test.js
Normal file
25
frontend/src/lib/navigation-notifications.test.js
Normal file
@ -0,0 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { NAV_NOTIFICATION_CATEGORIES } from "./navigation-notifications.ts";
|
||||
import { getVerbCategory } from "./verbTranslations.ts";
|
||||
|
||||
const notificationSamples = [
|
||||
["feed", "feed_new_post"],
|
||||
["messages", "chat_new_message"],
|
||||
["requests", "request_new"],
|
||||
["guests", "guest_visit_submitted"],
|
||||
["procurement", "procurement_new_request"],
|
||||
["tasks", "task_assigned"],
|
||||
["documents", "document_ready"],
|
||||
];
|
||||
|
||||
test("navigation badges use the same categories as notification verbs", () => {
|
||||
for (const [navigationKey, verb] of notificationSamples) {
|
||||
assert.equal(
|
||||
NAV_NOTIFICATION_CATEGORIES[navigationKey],
|
||||
getVerbCategory(verb),
|
||||
`${verb} must update the ${navigationKey} navigation badge`,
|
||||
);
|
||||
}
|
||||
});
|
||||
9
frontend/src/lib/navigation-notifications.ts
Normal file
9
frontend/src/lib/navigation-notifications.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export const NAV_NOTIFICATION_CATEGORIES = {
|
||||
feed: "Новости",
|
||||
messages: "Сообщения",
|
||||
requests: "Заявления",
|
||||
guests: "Гости",
|
||||
procurement: "Закупки",
|
||||
tasks: "Доска",
|
||||
documents: "Документы",
|
||||
} as const;
|
||||
@ -222,6 +222,9 @@ export interface TaskCard {
|
||||
linked_attendance_records_count?: number;
|
||||
linked_objects_count?: number;
|
||||
comments_count?: number;
|
||||
attachments_count?: number;
|
||||
checklist_total?: number;
|
||||
checklist_completed?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@ -230,6 +233,7 @@ export interface TaskBoard {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
access_scope?: "all" | "private" | "restricted";
|
||||
created_by?: User;
|
||||
members?: number[];
|
||||
member_details?: User[];
|
||||
@ -451,22 +455,57 @@ export interface TaskLinkedAttendanceRecord {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TaskExternalLink {
|
||||
id: number;
|
||||
task: number;
|
||||
url: string;
|
||||
title: string;
|
||||
created_by?: User;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TaskActivity {
|
||||
id: number;
|
||||
action: "created" | "updated" | "moved" | "linked" | "unlinked";
|
||||
action: "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";
|
||||
action_display?: string;
|
||||
actor?: User | null;
|
||||
object_kind?: "post" | "message" | "calendar_event" | "document" | "request" | "procurement_request" | "employee" | "guest" | "guest_visit" | "attendance_record" | "";
|
||||
object_kind?: "post" | "message" | "calendar_event" | "document" | "request" | "procurement_request" | "employee" | "guest" | "guest_visit" | "attendance_record" | "external_link" | "checklist_item" | "comment" | "";
|
||||
object_id?: number | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TaskAttachment {
|
||||
id: number;
|
||||
task: number;
|
||||
file_name: string;
|
||||
file_size: number;
|
||||
mime_type?: string;
|
||||
uploaded_by?: User;
|
||||
created_at: string;
|
||||
download_url: string;
|
||||
}
|
||||
|
||||
export interface TaskChecklistItem {
|
||||
id: number;
|
||||
task: number;
|
||||
title: string;
|
||||
position: number;
|
||||
is_completed: boolean;
|
||||
created_by?: User;
|
||||
completed_by?: User | null;
|
||||
completed_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TaskComment {
|
||||
id: number;
|
||||
task: number;
|
||||
author: User;
|
||||
text: string;
|
||||
is_edited?: boolean;
|
||||
edited_at?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@ -660,7 +699,14 @@ export interface Document {
|
||||
departments?: { id: number; name: string }[];
|
||||
acknowledgements?: DocumentAcknowledgement[];
|
||||
acknowledgement_required?: boolean;
|
||||
acknowledgement_for_all?: boolean;
|
||||
acknowledgement_required_for_user?: boolean;
|
||||
acknowledgement_recipients?: User[];
|
||||
acknowledgement_departments?: { id: number; name: string }[];
|
||||
is_acknowledged?: boolean;
|
||||
acknowledged_count?: number;
|
||||
acknowledgement_total?: number;
|
||||
comments_count?: number;
|
||||
linked_tasks?: MessageLinkedTask[];
|
||||
}
|
||||
|
||||
@ -1008,6 +1054,17 @@ export type ProcurementItemExecutionStatus = 'pending' | 'ordered' | 'rejected'
|
||||
export type UrgencyLevel = 'low' | 'medium' | 'high' | 'critical';
|
||||
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
export interface ProcurementItemAttachment {
|
||||
id: number;
|
||||
file_name: string;
|
||||
file_size: number;
|
||||
mime_type?: string;
|
||||
uploaded_by: number;
|
||||
uploaded_by_name?: string;
|
||||
created_at: string;
|
||||
download_url?: string;
|
||||
}
|
||||
|
||||
export interface ProcurementItem {
|
||||
id: number;
|
||||
request: number;
|
||||
@ -1027,6 +1084,8 @@ export interface ProcurementItem {
|
||||
received_quantity?: number | null;
|
||||
initial_comment?: string;
|
||||
comments_count?: number;
|
||||
attachments?: ProcurementItemAttachment[];
|
||||
attachments_count?: number;
|
||||
equipment?: number | null;
|
||||
}
|
||||
|
||||
@ -1110,6 +1169,7 @@ export interface ProcurementRequest {
|
||||
total_received_quantity?: number;
|
||||
quantity_unit_label?: string;
|
||||
comments_count?: number;
|
||||
activity_count?: number;
|
||||
can_current_user_approve?: boolean;
|
||||
can_current_user_submit_for_approval?: boolean;
|
||||
can_current_user_start_work?: boolean;
|
||||
@ -1122,6 +1182,17 @@ export interface ProcurementRequest {
|
||||
completed_at?: string | null;
|
||||
}
|
||||
|
||||
export interface ProcurementRequestActivity {
|
||||
id: number;
|
||||
action: string;
|
||||
action_display: string;
|
||||
actor?: User | null;
|
||||
object_kind?: string;
|
||||
object_id?: number | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ProcurementComment {
|
||||
id: number;
|
||||
request: number;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user