2021 lines
70 KiB
Python
2021 lines
70 KiB
Python
from urllib.parse import urlsplit
|
||
|
||
from django.db.models import Count, 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
|
||
|
||
from attendance.models import AttendanceRecord
|
||
from api.v1.documents.serializers import DocumentReadSerializer
|
||
from api.v1.employees.serializers import EmployeeBriefSerializer
|
||
from api.v1.employees.serializers.department import DepartmentBriefSerializer
|
||
from communications.models import Message
|
||
from communications.comments_helpers import get_comment_count
|
||
from communications.serialization import serialize_message
|
||
from communications.utils import user_can_access_chat
|
||
from documents.models import Document
|
||
from employees.models import Department, EmployeeDepartment
|
||
from employees.services.personnel_state import resolve_employee_personnel_state
|
||
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 schedule.models import Event
|
||
from api.v1.schedule.serializers import EventSerializer
|
||
from tasks.access import (
|
||
user_can_access_calendar_event,
|
||
user_can_access_document,
|
||
user_can_access_employee,
|
||
user_can_access_employee_request,
|
||
user_can_access_feed_post,
|
||
user_can_access_guest,
|
||
user_can_access_guest_visit,
|
||
user_can_access_attendance_record,
|
||
user_can_access_procurement_request,
|
||
user_can_access_task_board,
|
||
)
|
||
from tasks.automation.schema import (
|
||
AutomationSchemaError,
|
||
validate_automation_payload,
|
||
)
|
||
from tasks.models import (
|
||
Task,
|
||
TaskActivity,
|
||
TaskAttachment,
|
||
TaskAutomation,
|
||
TaskAutomationKind,
|
||
TaskAutomationRun,
|
||
TaskBoard,
|
||
TaskBoardAccessScope,
|
||
TaskBoardGroup,
|
||
TaskBoardPlacement,
|
||
TaskChecklistItem,
|
||
TaskColumn,
|
||
TaskColumnRow,
|
||
TaskCover,
|
||
TaskCoverType,
|
||
TaskExternalLink,
|
||
TaskLabel,
|
||
TaskLinkedObject,
|
||
TaskLinkedObjectKind,
|
||
TaskUserSettings,
|
||
)
|
||
|
||
User = get_user_model()
|
||
|
||
|
||
DEFAULT_COLUMNS = [
|
||
("Новые", "#38bdf8", False),
|
||
("В работе", "#f59e0b", False),
|
||
("На проверке", "#8b5cf6", False),
|
||
("Готово", "#22c55e", True),
|
||
]
|
||
|
||
|
||
def create_default_columns(board):
|
||
if board.columns.exists():
|
||
return
|
||
TaskColumn.objects.bulk_create(
|
||
[
|
||
TaskColumn(
|
||
board=board,
|
||
name=name,
|
||
color=color,
|
||
is_done=is_done,
|
||
position=index * 1000,
|
||
)
|
||
for index, (name, color, is_done) in enumerate(DEFAULT_COLUMNS)
|
||
]
|
||
)
|
||
|
||
|
||
def validate_board_access(serializer, board):
|
||
request = serializer.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
if not user_can_access_task_board(user, board):
|
||
raise serializers.ValidationError("У вас нет доступа к этой доске.")
|
||
return board
|
||
|
||
|
||
class TaskLabelSerializer(serializers.ModelSerializer):
|
||
class Meta:
|
||
model = TaskLabel
|
||
fields = ["id", "name", "color"]
|
||
read_only_fields = ["id"]
|
||
|
||
def validate_name(self, value):
|
||
name = value.strip()
|
||
duplicate = TaskLabel.objects.only("id", "name")
|
||
if self.instance:
|
||
duplicate = duplicate.exclude(pk=self.instance.pk)
|
||
normalized_name = name.casefold()
|
||
if any(label.name.casefold() == normalized_name for label in duplicate):
|
||
raise serializers.ValidationError("Метка с таким названием уже существует.")
|
||
return name
|
||
|
||
|
||
class TaskAutomationRunSerializer(serializers.ModelSerializer):
|
||
automation_name = serializers.CharField(source="automation.name", read_only=True)
|
||
task_title = serializers.CharField(source="task.title", read_only=True)
|
||
actor = EmployeeBriefSerializer(read_only=True)
|
||
|
||
class Meta:
|
||
model = TaskAutomationRun
|
||
fields = [
|
||
"id",
|
||
"automation",
|
||
"automation_name",
|
||
"task",
|
||
"task_title",
|
||
"trigger_activity",
|
||
"actor",
|
||
"source",
|
||
"status",
|
||
"context",
|
||
"actions_log",
|
||
"error",
|
||
"started_at",
|
||
"finished_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
|
||
class TaskAutomationSerializer(serializers.ModelSerializer):
|
||
"""Validate shared automation rules exposed to authenticated task users.
|
||
|
||
``can_manage`` intentionally mirrors the temporary broad API permission. It
|
||
should use the dedicated automation permission once that role is introduced.
|
||
"""
|
||
|
||
created_by = EmployeeBriefSerializer(read_only=True)
|
||
boards = serializers.PrimaryKeyRelatedField(
|
||
queryset=TaskBoard.objects.filter(is_archived=False),
|
||
many=True,
|
||
required=False,
|
||
)
|
||
runs_count = serializers.IntegerField(read_only=True, default=0)
|
||
can_manage = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = TaskAutomation
|
||
fields = [
|
||
"id",
|
||
"name",
|
||
"description",
|
||
"kind",
|
||
"trigger",
|
||
"conditions",
|
||
"actions",
|
||
"schedule_config",
|
||
"applies_to_all_boards",
|
||
"boards",
|
||
"is_active",
|
||
"stop_on_error",
|
||
"position",
|
||
"created_by",
|
||
"last_run_at",
|
||
"runs_count",
|
||
"can_manage",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
read_only_fields = [
|
||
"id",
|
||
"created_by",
|
||
"last_run_at",
|
||
"runs_count",
|
||
"can_manage",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
def get_can_manage(self, obj):
|
||
user = getattr(self.context.get("request"), "user", None)
|
||
return bool(user and user.is_authenticated)
|
||
|
||
def validate_name(self, value):
|
||
name = value.strip()
|
||
if not name:
|
||
raise serializers.ValidationError("Введите название автоматизации.")
|
||
return name
|
||
|
||
def validate(self, attrs):
|
||
attrs = super().validate(attrs)
|
||
instance = self.instance
|
||
kind = attrs.get("kind", getattr(instance, "kind", TaskAutomationKind.EVENT))
|
||
trigger = attrs.get("trigger", getattr(instance, "trigger", "task_updated"))
|
||
conditions = attrs.get("conditions", getattr(instance, "conditions", {}))
|
||
actions = attrs.get("actions", getattr(instance, "actions", []))
|
||
schedule_config = attrs.get(
|
||
"schedule_config",
|
||
getattr(instance, "schedule_config", {}),
|
||
)
|
||
try:
|
||
normalized = validate_automation_payload(
|
||
kind=kind,
|
||
trigger=trigger,
|
||
conditions=conditions,
|
||
actions=actions,
|
||
schedule_config=schedule_config,
|
||
)
|
||
except AutomationSchemaError as error:
|
||
raise serializers.ValidationError({"detail": str(error)}) from error
|
||
attrs.update(normalized)
|
||
|
||
applies_to_all = attrs.get(
|
||
"applies_to_all_boards",
|
||
getattr(instance, "applies_to_all_boards", True),
|
||
)
|
||
boards = attrs.get("boards")
|
||
if not applies_to_all and boards is not None and not boards:
|
||
raise serializers.ValidationError(
|
||
{"boards": "Выберите хотя бы одну доску или включите все доски."}
|
||
)
|
||
if applies_to_all:
|
||
attrs["boards"] = []
|
||
return attrs
|
||
|
||
|
||
class TaskColumnSerializer(serializers.ModelSerializer):
|
||
tasks_count = serializers.IntegerField(read_only=True, default=0)
|
||
subcolumns_count = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = TaskColumn
|
||
validators = []
|
||
fields = [
|
||
"id",
|
||
"board",
|
||
"parent",
|
||
"name",
|
||
"position",
|
||
"color",
|
||
"is_done",
|
||
"is_archived",
|
||
"tasks_count",
|
||
"subcolumns_count",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
read_only_fields = [
|
||
"id",
|
||
"created_at",
|
||
"updated_at",
|
||
"tasks_count",
|
||
"subcolumns_count",
|
||
]
|
||
|
||
def validate_board(self, board):
|
||
return validate_board_access(self, board)
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_subcolumns_count(self, obj):
|
||
annotated_count = getattr(obj, "subcolumns_count", None)
|
||
if annotated_count is not None:
|
||
return annotated_count
|
||
return obj.subcolumns.filter(is_archived=False).count()
|
||
|
||
def validate(self, attrs):
|
||
board = attrs.get("board") or getattr(self.instance, "board", None)
|
||
parent = attrs.get("parent", getattr(self.instance, "parent", None))
|
||
|
||
if self.instance and board and self.instance.board_id != board.id:
|
||
raise serializers.ValidationError(
|
||
{"board": "Доску существующей колонки изменить нельзя."}
|
||
)
|
||
|
||
if self.instance and "parent" in attrs:
|
||
current_parent_id = self.instance.parent_id
|
||
requested_parent_id = parent.id if parent else None
|
||
if current_parent_id != requested_parent_id:
|
||
raise serializers.ValidationError(
|
||
{"parent": "Родителя существующей колонки изменить нельзя."}
|
||
)
|
||
|
||
if parent:
|
||
if board and parent.board_id != board.id:
|
||
raise serializers.ValidationError(
|
||
{"parent": "Подколонка должна принадлежать той же доске."}
|
||
)
|
||
if parent.parent_id:
|
||
raise serializers.ValidationError(
|
||
{"parent": "Допускается только один уровень подколонок."}
|
||
)
|
||
if parent.is_archived:
|
||
raise serializers.ValidationError(
|
||
{"parent": "Нельзя добавить подколонку в архивную колонку."}
|
||
)
|
||
attrs["is_done"] = parent.is_done
|
||
|
||
if (
|
||
self.instance
|
||
and attrs.get("is_archived") is True
|
||
and self.instance.subcolumns.exists()
|
||
):
|
||
raise serializers.ValidationError(
|
||
{"is_archived": "Сначала удалите подколонки."}
|
||
)
|
||
|
||
name = attrs.get("name", getattr(self.instance, "name", ""))
|
||
if board and name:
|
||
duplicate_columns = TaskColumn.objects.filter(name=name)
|
||
if parent:
|
||
duplicate_columns = duplicate_columns.filter(parent=parent)
|
||
else:
|
||
duplicate_columns = duplicate_columns.filter(
|
||
board=board,
|
||
parent__isnull=True,
|
||
)
|
||
if self.instance:
|
||
duplicate_columns = duplicate_columns.exclude(pk=self.instance.pk)
|
||
if duplicate_columns.exists():
|
||
raise serializers.ValidationError(
|
||
{"name": "Колонка с таким названием уже существует здесь."}
|
||
)
|
||
|
||
return attrs
|
||
|
||
def create(self, validated_data):
|
||
parent = validated_data.get("parent")
|
||
if parent:
|
||
validated_data.setdefault("color", parent.color)
|
||
validated_data.setdefault("is_done", parent.is_done)
|
||
return super().create(validated_data)
|
||
|
||
|
||
class TaskColumnRowSerializer(serializers.ModelSerializer):
|
||
tasks_count = serializers.IntegerField(read_only=True, default=0)
|
||
|
||
class Meta:
|
||
model = TaskColumnRow
|
||
fields = [
|
||
"id",
|
||
"column",
|
||
"name",
|
||
"position",
|
||
"color",
|
||
"tasks_count",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
read_only_fields = ["id", "tasks_count", "created_at", "updated_at"]
|
||
|
||
def validate_column(self, column):
|
||
validate_board_access(self, column.board)
|
||
if column.parent_id:
|
||
raise serializers.ValidationError(
|
||
"Дорожки можно создавать только в основной колонке."
|
||
)
|
||
if column.is_archived:
|
||
raise serializers.ValidationError(
|
||
"Нельзя создать дорожку в архивной колонке."
|
||
)
|
||
return column
|
||
|
||
def validate(self, attrs):
|
||
column = attrs.get("column") or getattr(self.instance, "column", None)
|
||
if self.instance and column and self.instance.column_id != column.id:
|
||
raise serializers.ValidationError(
|
||
{"column": "Основную колонку дорожки изменить нельзя."}
|
||
)
|
||
name = attrs.get("name", getattr(self.instance, "name", "")).strip()
|
||
if not name:
|
||
raise serializers.ValidationError({"name": "Укажите название дорожки."})
|
||
duplicate = TaskColumnRow.objects.filter(column=column, name__iexact=name)
|
||
if self.instance:
|
||
duplicate = duplicate.exclude(pk=self.instance.pk)
|
||
if duplicate.exists():
|
||
raise serializers.ValidationError(
|
||
{"name": "Дорожка с таким названием уже существует в колонке."}
|
||
)
|
||
attrs["name"] = name
|
||
return attrs
|
||
|
||
|
||
class TaskSerializer(serializers.ModelSerializer):
|
||
created_by = EmployeeBriefSerializer(read_only=True)
|
||
assignee = EmployeeBriefSerializer(read_only=True)
|
||
participants = EmployeeBriefSerializer(many=True, read_only=True)
|
||
participant_ids = serializers.PrimaryKeyRelatedField(
|
||
source="participants",
|
||
queryset=User.objects.filter(is_active=True),
|
||
many=True,
|
||
required=False,
|
||
write_only=True,
|
||
)
|
||
assignee_id = serializers.PrimaryKeyRelatedField(
|
||
source="assignee",
|
||
queryset=User.objects.all(),
|
||
required=False,
|
||
allow_null=True,
|
||
write_only=True,
|
||
)
|
||
labels = TaskLabelSerializer(many=True, read_only=True)
|
||
label_ids = serializers.PrimaryKeyRelatedField(
|
||
source="labels",
|
||
queryset=TaskLabel.objects.all(),
|
||
many=True,
|
||
required=False,
|
||
write_only=True,
|
||
)
|
||
priority_display = serializers.CharField(
|
||
source="get_priority_display",
|
||
read_only=True,
|
||
)
|
||
board_name = serializers.CharField(source="board.name", read_only=True)
|
||
column_name = serializers.CharField(source="column.name", read_only=True)
|
||
column_color = serializers.CharField(source="column.color", read_only=True)
|
||
row_name = serializers.CharField(source="row.name", read_only=True)
|
||
row_color = serializers.CharField(source="row.color", read_only=True)
|
||
linked_posts_count = serializers.SerializerMethodField()
|
||
linked_messages_count = serializers.SerializerMethodField()
|
||
linked_events_count = serializers.SerializerMethodField()
|
||
linked_documents_count = serializers.SerializerMethodField()
|
||
linked_requests_count = serializers.SerializerMethodField()
|
||
linked_procurement_requests_count = serializers.SerializerMethodField()
|
||
linked_employees_count = serializers.SerializerMethodField()
|
||
linked_guests_count = serializers.SerializerMethodField()
|
||
linked_guest_visits_count = serializers.SerializerMethodField()
|
||
linked_attendance_records_count = serializers.SerializerMethodField()
|
||
linked_objects_count = serializers.SerializerMethodField()
|
||
comments_count = serializers.SerializerMethodField()
|
||
attachments_count = serializers.SerializerMethodField()
|
||
cover = serializers.SerializerMethodField()
|
||
cover_attachment = serializers.SerializerMethodField()
|
||
checklist_total = serializers.SerializerMethodField()
|
||
checklist_completed = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = Task
|
||
fields = [
|
||
"id",
|
||
"board",
|
||
"board_name",
|
||
"column",
|
||
"column_name",
|
||
"column_color",
|
||
"row",
|
||
"row_name",
|
||
"row_color",
|
||
"title",
|
||
"description",
|
||
"created_by",
|
||
"assignee",
|
||
"assignee_id",
|
||
"participants",
|
||
"participant_ids",
|
||
"labels",
|
||
"label_ids",
|
||
"priority",
|
||
"priority_display",
|
||
"due_date",
|
||
"position",
|
||
"completed_at",
|
||
"linked_posts_count",
|
||
"linked_messages_count",
|
||
"linked_events_count",
|
||
"linked_documents_count",
|
||
"linked_requests_count",
|
||
"linked_procurement_requests_count",
|
||
"linked_employees_count",
|
||
"linked_guests_count",
|
||
"linked_guest_visits_count",
|
||
"linked_attendance_records_count",
|
||
"linked_objects_count",
|
||
"comments_count",
|
||
"attachments_count",
|
||
"cover",
|
||
"cover_attachment",
|
||
"checklist_total",
|
||
"checklist_completed",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
read_only_fields = [
|
||
"id",
|
||
"created_by",
|
||
"participants",
|
||
"board_name",
|
||
"column_name",
|
||
"column_color",
|
||
"row_name",
|
||
"row_color",
|
||
"priority_display",
|
||
"completed_at",
|
||
"linked_posts_count",
|
||
"linked_messages_count",
|
||
"linked_events_count",
|
||
"linked_documents_count",
|
||
"linked_requests_count",
|
||
"linked_procurement_requests_count",
|
||
"linked_employees_count",
|
||
"linked_guests_count",
|
||
"linked_guest_visits_count",
|
||
"linked_attendance_records_count",
|
||
"linked_objects_count",
|
||
"comments_count",
|
||
"attachments_count",
|
||
"cover",
|
||
"cover_attachment",
|
||
"checklist_total",
|
||
"checklist_completed",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_linked_posts_count(self, obj):
|
||
return self._get_linked_kind_count(
|
||
obj,
|
||
"linked_posts_count",
|
||
TaskLinkedObjectKind.POST,
|
||
)
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_linked_messages_count(self, obj):
|
||
return self._get_linked_kind_count(
|
||
obj,
|
||
"linked_messages_count",
|
||
TaskLinkedObjectKind.MESSAGE,
|
||
)
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_linked_events_count(self, obj):
|
||
return self._get_linked_kind_count(
|
||
obj,
|
||
"linked_events_count",
|
||
TaskLinkedObjectKind.CALENDAR_EVENT,
|
||
)
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_linked_documents_count(self, obj):
|
||
return self._get_linked_kind_count(
|
||
obj,
|
||
"linked_documents_count",
|
||
TaskLinkedObjectKind.DOCUMENT,
|
||
)
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_linked_requests_count(self, obj):
|
||
return self._get_linked_kind_count(
|
||
obj,
|
||
"linked_requests_count",
|
||
TaskLinkedObjectKind.REQUEST,
|
||
)
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_linked_procurement_requests_count(self, obj):
|
||
return self._get_linked_kind_count(
|
||
obj,
|
||
"linked_procurement_requests_count",
|
||
TaskLinkedObjectKind.PROCUREMENT_REQUEST,
|
||
)
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_linked_employees_count(self, obj):
|
||
return self._get_linked_kind_count(
|
||
obj,
|
||
"linked_employees_count",
|
||
TaskLinkedObjectKind.EMPLOYEE,
|
||
)
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_linked_guests_count(self, obj):
|
||
return self._get_linked_kind_count(
|
||
obj,
|
||
"linked_guests_count",
|
||
TaskLinkedObjectKind.GUEST,
|
||
)
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_linked_guest_visits_count(self, obj):
|
||
return self._get_linked_kind_count(
|
||
obj,
|
||
"linked_guest_visits_count",
|
||
TaskLinkedObjectKind.GUEST_VISIT,
|
||
)
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_linked_attendance_records_count(self, obj):
|
||
return self._get_linked_kind_count(
|
||
obj,
|
||
"linked_attendance_records_count",
|
||
TaskLinkedObjectKind.ATTENDANCE_RECORD,
|
||
)
|
||
|
||
def _get_linked_kind_count(self, obj, attribute_name, kind):
|
||
if hasattr(obj, attribute_name):
|
||
return getattr(obj, attribute_name)
|
||
prefetched = getattr(obj, "_prefetched_objects_cache", {}).get("linked_objects")
|
||
if prefetched is not None:
|
||
return sum(link.kind == kind for link in prefetched)
|
||
return obj.linked_objects.filter(kind=kind).count()
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_linked_objects_count(self, obj):
|
||
if hasattr(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):
|
||
if hasattr(obj, "comments_count"):
|
||
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()
|
||
|
||
def _get_cover_payload(self, obj):
|
||
if hasattr(obj, "_task_cover_payload"):
|
||
return obj._task_cover_payload
|
||
try:
|
||
cover = obj.cover
|
||
except TaskCover.DoesNotExist:
|
||
cover = None
|
||
if (
|
||
cover is not None
|
||
and cover.kind == TaskCoverType.CHECKLIST
|
||
and not obj.checklist_items.exists()
|
||
):
|
||
cover = None
|
||
payload = (
|
||
TaskCoverSerializer(cover, context=self.context).data
|
||
if cover is not None
|
||
else None
|
||
)
|
||
obj._task_cover_payload = payload
|
||
return payload
|
||
|
||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||
def get_cover(self, obj):
|
||
return self._get_cover_payload(obj)
|
||
|
||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||
def get_cover_attachment(self, obj):
|
||
payload = self._get_cover_payload(obj)
|
||
return payload.get("attachment") if payload else None
|
||
|
||
@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)
|
||
board = attrs.get("board") or getattr(self.instance, "board", None)
|
||
column = attrs.get("column") or getattr(self.instance, "column", None)
|
||
row = (
|
||
attrs.get("row") if "row" in attrs else getattr(self.instance, "row", None)
|
||
)
|
||
participants = attrs.get("participants")
|
||
|
||
if board and not user_can_access_task_board(user, board):
|
||
raise serializers.ValidationError(
|
||
{"board": "У вас нет доступа к этой доске."}
|
||
)
|
||
|
||
if column and board and column.board_id != board.id:
|
||
raise serializers.ValidationError(
|
||
{"column": "Колонка должна принадлежать выбранной доске."}
|
||
)
|
||
|
||
if column and column.subcolumns.filter(is_archived=False).exists():
|
||
raise serializers.ValidationError(
|
||
{"column": "Выберите подколонку для размещения задачи."}
|
||
)
|
||
|
||
if row and column:
|
||
top_level_column_id = column.parent_id or column.id
|
||
if row.column_id != top_level_column_id:
|
||
raise serializers.ValidationError(
|
||
{"row": "Дорожка должна принадлежать основной колонке задачи."}
|
||
)
|
||
|
||
if participants is not None and board:
|
||
inaccessible_participants = [
|
||
participant.id
|
||
for participant in participants
|
||
if not user_can_access_task_board(participant, board)
|
||
]
|
||
if inaccessible_participants:
|
||
raise serializers.ValidationError(
|
||
{
|
||
"participant_ids": (
|
||
"Участниками могут быть только сотрудники с доступом "
|
||
"к этой доске."
|
||
)
|
||
}
|
||
)
|
||
|
||
return attrs
|
||
|
||
def create(self, validated_data):
|
||
labels = validated_data.pop("labels", [])
|
||
participants = list(validated_data.pop("participants", []))
|
||
if "position" not in validated_data:
|
||
column = validated_data["column"]
|
||
row = validated_data.get("row")
|
||
max_position = (
|
||
Task.objects.filter(column=column, row=row).aggregate(Max("position"))[
|
||
"position__max"
|
||
]
|
||
or 0
|
||
)
|
||
validated_data["position"] = max_position + 1000
|
||
task = Task.objects.create(**validated_data)
|
||
participant_ids = {participant.id for participant in participants}
|
||
participant_ids.add(task.created_by_id)
|
||
if task.assignee_id:
|
||
participant_ids.add(task.assignee_id)
|
||
task.participants.set(participant_ids)
|
||
if labels:
|
||
task.labels.set(labels)
|
||
return task
|
||
|
||
def update(self, instance, validated_data):
|
||
previous_assignee_id = instance.assignee_id
|
||
labels = validated_data.pop("labels", None)
|
||
participants = validated_data.pop("participants", None)
|
||
task = super().update(instance, validated_data)
|
||
if participants is not None:
|
||
participant_ids = {participant.id for participant in participants}
|
||
participant_ids.add(task.created_by_id)
|
||
if previous_assignee_id:
|
||
participant_ids.add(previous_assignee_id)
|
||
if task.assignee_id:
|
||
participant_ids.add(task.assignee_id)
|
||
task.participants.set(participant_ids)
|
||
else:
|
||
retained_participant_ids = [task.assignee_id, previous_assignee_id]
|
||
task.participants.add(
|
||
*[
|
||
participant_id
|
||
for participant_id in retained_participant_ids
|
||
if participant_id
|
||
]
|
||
)
|
||
if labels is not None:
|
||
task.labels.set(labels)
|
||
return task
|
||
|
||
|
||
class TaskBoardSerializer(serializers.ModelSerializer):
|
||
created_by = EmployeeBriefSerializer(read_only=True)
|
||
members = serializers.PrimaryKeyRelatedField(
|
||
queryset=User.objects.all(),
|
||
many=True,
|
||
required=False,
|
||
)
|
||
member_details = EmployeeBriefSerializer(
|
||
source="members",
|
||
many=True,
|
||
read_only=True,
|
||
)
|
||
departments = serializers.PrimaryKeyRelatedField(
|
||
queryset=Department.objects.all(),
|
||
many=True,
|
||
required=False,
|
||
)
|
||
department_details = DepartmentBriefSerializer(
|
||
source="departments",
|
||
many=True,
|
||
read_only=True,
|
||
)
|
||
columns = TaskColumnSerializer(many=True, read_only=True)
|
||
column_rows = serializers.SerializerMethodField()
|
||
tasks = TaskSerializer(many=True, read_only=True)
|
||
labels = serializers.SerializerMethodField()
|
||
tasks_count = serializers.IntegerField(read_only=True, default=0)
|
||
is_default_for_current_user = serializers.SerializerMethodField()
|
||
group_id = serializers.IntegerField(
|
||
source="current_user_group_id",
|
||
read_only=True,
|
||
allow_null=True,
|
||
default=None,
|
||
)
|
||
is_pinned = serializers.BooleanField(
|
||
source="current_user_is_pinned",
|
||
read_only=True,
|
||
default=False,
|
||
)
|
||
notifications_enabled = serializers.BooleanField(
|
||
source="current_user_notifications_enabled",
|
||
read_only=True,
|
||
default=True,
|
||
)
|
||
view_state = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = TaskBoard
|
||
fields = [
|
||
"id",
|
||
"name",
|
||
"description",
|
||
"avatar",
|
||
"access_scope",
|
||
"created_by",
|
||
"members",
|
||
"member_details",
|
||
"departments",
|
||
"department_details",
|
||
"is_archived",
|
||
"columns",
|
||
"column_rows",
|
||
"tasks",
|
||
"labels",
|
||
"tasks_count",
|
||
"is_default_for_current_user",
|
||
"group_id",
|
||
"is_pinned",
|
||
"notifications_enabled",
|
||
"view_state",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
@extend_schema_field(TaskLabelSerializer(many=True))
|
||
def get_labels(self, obj):
|
||
labels = getattr(self, "_global_labels_cache", None)
|
||
if labels is None:
|
||
labels = list(TaskLabel.objects.order_by("name", "id"))
|
||
self._global_labels_cache = labels
|
||
return TaskLabelSerializer(labels, many=True).data
|
||
|
||
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.DictField())
|
||
def get_view_state(self, obj):
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
if not user or not getattr(user, "is_authenticated", False):
|
||
return {
|
||
"hidden_column_ids": [],
|
||
"collapsed_base_column_ids": [],
|
||
"collapsed_row_ids": [],
|
||
}
|
||
raw_state = (
|
||
TaskBoardPlacement.objects.filter(user=user, board=obj)
|
||
.values_list("view_state", flat=True)
|
||
.first()
|
||
or {}
|
||
)
|
||
valid_column_ids = set(
|
||
obj.columns.filter(parent__isnull=True, is_archived=False).values_list(
|
||
"id", flat=True
|
||
)
|
||
)
|
||
valid_row_ids = set(
|
||
TaskColumnRow.objects.filter(
|
||
column__board=obj,
|
||
column__is_archived=False,
|
||
).values_list("id", flat=True)
|
||
)
|
||
|
||
def valid_ids(key, allowed_ids):
|
||
values = raw_state.get(key, []) if isinstance(raw_state, dict) else []
|
||
if not isinstance(values, list):
|
||
return []
|
||
return sorted(
|
||
{
|
||
value
|
||
for value in values
|
||
if isinstance(value, int) and value in allowed_ids
|
||
}
|
||
)
|
||
|
||
return {
|
||
"hidden_column_ids": valid_ids("hidden_column_ids", valid_column_ids),
|
||
"collapsed_base_column_ids": valid_ids(
|
||
"collapsed_base_column_ids", valid_column_ids
|
||
),
|
||
"collapsed_row_ids": valid_ids("collapsed_row_ids", valid_row_ids),
|
||
}
|
||
|
||
@extend_schema_field(TaskColumnRowSerializer(many=True))
|
||
def get_column_rows(self, obj):
|
||
rows = (
|
||
TaskColumnRow.objects.filter(
|
||
column__board=obj,
|
||
)
|
||
.annotate(tasks_count=Count("tasks", distinct=True))
|
||
.order_by("column_id", "position", "id")
|
||
)
|
||
return TaskColumnRowSerializer(rows, many=True).data
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_is_default_for_current_user(self, obj):
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
if not user or not getattr(user, "is_authenticated", False):
|
||
return False
|
||
settings_obj = getattr(user, "task_settings", None)
|
||
return bool(settings_obj and settings_obj.default_board_id == obj.id)
|
||
|
||
|
||
class TaskBoardSummarySerializer(TaskBoardSerializer):
|
||
open_tasks_count = serializers.IntegerField(read_only=True, default=0)
|
||
completed_tasks_count = serializers.IntegerField(read_only=True, default=0)
|
||
|
||
class Meta(TaskBoardSerializer.Meta):
|
||
fields = [
|
||
"id",
|
||
"name",
|
||
"description",
|
||
"avatar",
|
||
"access_scope",
|
||
"created_by",
|
||
"member_details",
|
||
"department_details",
|
||
"tasks_count",
|
||
"open_tasks_count",
|
||
"completed_tasks_count",
|
||
"is_default_for_current_user",
|
||
"group_id",
|
||
"is_pinned",
|
||
"notifications_enabled",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
|
||
class TaskBoardGroupSerializer(serializers.ModelSerializer):
|
||
boards_count = serializers.IntegerField(read_only=True, default=0)
|
||
is_default_for_current_user = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = TaskBoardGroup
|
||
fields = [
|
||
"id",
|
||
"name",
|
||
"position",
|
||
"boards_count",
|
||
"is_default_for_current_user",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
read_only_fields = ["id", "created_at", "updated_at"]
|
||
|
||
def validate_name(self, value):
|
||
name = value.strip()
|
||
if not name:
|
||
raise serializers.ValidationError("Укажите название группы.")
|
||
request = self.context.get("request")
|
||
queryset = TaskBoardGroup.objects.filter(
|
||
user=getattr(request, "user", None),
|
||
name__iexact=name,
|
||
)
|
||
if self.instance:
|
||
queryset = queryset.exclude(id=self.instance.id)
|
||
if queryset.exists():
|
||
raise serializers.ValidationError(
|
||
"Группа с таким названием уже существует."
|
||
)
|
||
return name
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_is_default_for_current_user(self, obj):
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
if not user or not getattr(user, "is_authenticated", False):
|
||
return False
|
||
if not hasattr(self, "_default_group_id"):
|
||
self._default_group_id = (
|
||
TaskUserSettings.objects.filter(user=user)
|
||
.values_list("default_group_id", flat=True)
|
||
.first()
|
||
)
|
||
return self._default_group_id == obj.id
|
||
|
||
|
||
class TaskLinkedPostSerializer(serializers.ModelSerializer):
|
||
created_by = EmployeeBriefSerializer(read_only=True)
|
||
post = serializers.SerializerMethodField()
|
||
post_id = serializers.IntegerField(source="object_id", read_only=True)
|
||
can_open = serializers.SerializerMethodField()
|
||
object_url = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = TaskLinkedObject
|
||
fields = [
|
||
"id",
|
||
"kind",
|
||
"post_id",
|
||
"post",
|
||
"can_open",
|
||
"object_url",
|
||
"created_by",
|
||
"created_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||
def get_post(self, obj):
|
||
if obj.kind != TaskLinkedObjectKind.POST:
|
||
return None
|
||
|
||
post = getattr(obj, "content_object", None)
|
||
if post is None:
|
||
return None
|
||
|
||
request = self.context.get("request")
|
||
from api.v1.utils import build_media_url
|
||
|
||
return {
|
||
"id": post.id,
|
||
"type": post.type,
|
||
"department": post.department_id,
|
||
"department_id": post.department_id,
|
||
"department_name": getattr(post.department, "name", None),
|
||
"title": post.title,
|
||
"body": post.body,
|
||
"image": build_media_url(post.image, request),
|
||
"attachment": build_media_url(post.attachment, request),
|
||
"created_at": post.created_at,
|
||
"pinned": post.pinned_global or post.pinned_department,
|
||
"pinned_global": post.pinned_global,
|
||
"pinned_department": post.pinned_department,
|
||
"likes_count": post.likes_count,
|
||
"comments_count": get_comment_count(post),
|
||
"author_id": post.author_id,
|
||
"author": EmployeeBriefSerializer(post.author).data,
|
||
}
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_open(self, obj):
|
||
post = getattr(obj, "content_object", None)
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
if obj.kind != TaskLinkedObjectKind.POST or post is None or not user:
|
||
return False
|
||
return user_can_access_feed_post(user, post)
|
||
|
||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||
def get_object_url(self, obj):
|
||
post = getattr(obj, "content_object", None)
|
||
if post is None or not self.get_can_open(obj):
|
||
return None
|
||
return f"/?post={post.id}"
|
||
|
||
|
||
def get_post_content_type():
|
||
return ContentType.objects.get_for_model(Post)
|
||
|
||
|
||
class TaskLinkedMessageSerializer(serializers.ModelSerializer):
|
||
created_by = EmployeeBriefSerializer(read_only=True)
|
||
message = serializers.SerializerMethodField()
|
||
message_id = serializers.IntegerField(source="object_id", read_only=True)
|
||
can_open = serializers.SerializerMethodField()
|
||
object_url = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = TaskLinkedObject
|
||
fields = [
|
||
"id",
|
||
"kind",
|
||
"message_id",
|
||
"message",
|
||
"can_open",
|
||
"object_url",
|
||
"created_by",
|
||
"created_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||
def get_message(self, obj):
|
||
if obj.kind != TaskLinkedObjectKind.MESSAGE:
|
||
return None
|
||
|
||
message = getattr(obj, "content_object", None)
|
||
if message is None:
|
||
return None
|
||
|
||
return serialize_message(message, include_linked_tasks=False)
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_open(self, obj):
|
||
message = getattr(obj, "content_object", None)
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
if obj.kind != TaskLinkedObjectKind.MESSAGE or message is None or not user:
|
||
return False
|
||
return user_can_access_chat(message.chat, user)
|
||
|
||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||
def get_object_url(self, obj):
|
||
message = getattr(obj, "content_object", None)
|
||
if message is None or not self.get_can_open(obj):
|
||
return None
|
||
return f"/messages/{message.chat_id}?message={message.id}"
|
||
|
||
|
||
def get_message_content_type():
|
||
return ContentType.objects.get_for_model(Message)
|
||
|
||
|
||
class TaskActivitySerializer(serializers.ModelSerializer):
|
||
actor = EmployeeBriefSerializer(read_only=True)
|
||
action_display = serializers.CharField(source="get_action_display", read_only=True)
|
||
|
||
class Meta:
|
||
model = TaskActivity
|
||
fields = [
|
||
"id",
|
||
"action",
|
||
"action_display",
|
||
"actor",
|
||
"object_kind",
|
||
"object_id",
|
||
"metadata",
|
||
"created_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
|
||
class TaskAttachmentSerializer(serializers.ModelSerializer):
|
||
uploaded_by = EmployeeBriefSerializer(read_only=True)
|
||
download_url = serializers.SerializerMethodField()
|
||
thumbnail_url = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = TaskAttachment
|
||
fields = [
|
||
"id",
|
||
"task",
|
||
"comment",
|
||
"file_name",
|
||
"file_size",
|
||
"mime_type",
|
||
"uploaded_by",
|
||
"created_at",
|
||
"download_url",
|
||
"thumbnail_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
|
||
|
||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||
def get_thumbnail_url(self, obj):
|
||
from tasks.media import task_attachment_media_kind
|
||
|
||
if not task_attachment_media_kind(obj):
|
||
return None
|
||
url = reverse(
|
||
"api:v1:tasks:task-attachment-thumbnail",
|
||
kwargs={"pk": obj.task_id, "attachment_id": obj.id},
|
||
)
|
||
request = self.context.get("request")
|
||
return request.build_absolute_uri(url) if request else url
|
||
|
||
|
||
class TaskChecklistItemSerializer(serializers.ModelSerializer):
|
||
created_by = EmployeeBriefSerializer(read_only=True)
|
||
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()
|
||
event_id = serializers.IntegerField(source="object_id", read_only=True)
|
||
can_open = serializers.SerializerMethodField()
|
||
object_url = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = TaskLinkedObject
|
||
fields = [
|
||
"id",
|
||
"kind",
|
||
"event_id",
|
||
"event",
|
||
"can_open",
|
||
"object_url",
|
||
"created_by",
|
||
"created_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||
def get_event(self, obj):
|
||
if obj.kind != TaskLinkedObjectKind.CALENDAR_EVENT:
|
||
return None
|
||
|
||
event = getattr(obj, "content_object", None)
|
||
if event is None:
|
||
return None
|
||
|
||
return EventSerializer(event, context=self.context).data
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_open(self, obj):
|
||
event = getattr(obj, "content_object", None)
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
if obj.kind != TaskLinkedObjectKind.CALENDAR_EVENT or event is None or not user:
|
||
return False
|
||
return user_can_access_calendar_event(user, event)
|
||
|
||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||
def get_object_url(self, obj):
|
||
event = getattr(obj, "content_object", None)
|
||
if event is None or not self.get_can_open(obj):
|
||
return None
|
||
return f"/calendar?calendar={event.calendar_id}&event={event.id}"
|
||
|
||
|
||
def get_calendar_event_content_type():
|
||
return ContentType.objects.get_for_model(Event)
|
||
|
||
|
||
class TaskLinkedDocumentSerializer(serializers.ModelSerializer):
|
||
created_by = EmployeeBriefSerializer(read_only=True)
|
||
document = serializers.SerializerMethodField()
|
||
document_id = serializers.IntegerField(source="object_id", read_only=True)
|
||
can_open = serializers.SerializerMethodField()
|
||
object_url = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = TaskLinkedObject
|
||
fields = [
|
||
"id",
|
||
"kind",
|
||
"document_id",
|
||
"document",
|
||
"can_open",
|
||
"object_url",
|
||
"created_by",
|
||
"created_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||
def get_document(self, obj):
|
||
if obj.kind != TaskLinkedObjectKind.DOCUMENT:
|
||
return None
|
||
|
||
document = getattr(obj, "content_object", None)
|
||
if document is None:
|
||
return None
|
||
|
||
if not self.get_can_open(obj):
|
||
return {
|
||
"id": document.id,
|
||
"title": document.title,
|
||
"description": document.description,
|
||
"is_regulation": document.is_regulation,
|
||
"folder_path": document.folder_path,
|
||
"file_name": (
|
||
getattr(document.file, "name", None) if document.file else None
|
||
),
|
||
}
|
||
|
||
return DocumentReadSerializer(document, context=self.context).data
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_open(self, obj):
|
||
document = getattr(obj, "content_object", None)
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
if obj.kind != TaskLinkedObjectKind.DOCUMENT or document is None or not user:
|
||
return False
|
||
return user_can_access_document(user, document)
|
||
|
||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||
def get_object_url(self, obj):
|
||
document = getattr(obj, "content_object", None)
|
||
if document is None or not self.get_can_open(obj):
|
||
return None
|
||
return f"/documents?document={document.id}"
|
||
|
||
|
||
def get_document_content_type():
|
||
return ContentType.objects.get_for_model(Document)
|
||
|
||
|
||
class TaskLinkedRequestSerializer(serializers.ModelSerializer):
|
||
created_by = EmployeeBriefSerializer(read_only=True)
|
||
request = serializers.SerializerMethodField()
|
||
request_id = serializers.IntegerField(source="object_id", read_only=True)
|
||
can_open = serializers.SerializerMethodField()
|
||
object_url = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = TaskLinkedObject
|
||
fields = [
|
||
"id",
|
||
"kind",
|
||
"request_id",
|
||
"request",
|
||
"can_open",
|
||
"object_url",
|
||
"created_by",
|
||
"created_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||
def get_request(self, obj):
|
||
if obj.kind != TaskLinkedObjectKind.REQUEST:
|
||
return None
|
||
|
||
request_obj = getattr(obj, "content_object", None)
|
||
if request_obj is None:
|
||
return None
|
||
|
||
return {
|
||
"id": request_obj.id,
|
||
"title": request_obj.title,
|
||
"display_title": request_obj.display_title,
|
||
"type": request_obj.type,
|
||
"type_display": request_obj.get_type_display(),
|
||
"status": request_obj.status,
|
||
"status_display": request_obj.get_status_display(),
|
||
"comment": request_obj.comment,
|
||
"date_from": request_obj.date_from,
|
||
"date_to": request_obj.date_to,
|
||
"employee": EmployeeBriefSerializer(request_obj.employee).data,
|
||
"created_at": request_obj.created_at,
|
||
"updated_at": request_obj.updated_at,
|
||
}
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_open(self, obj):
|
||
request_obj = getattr(obj, "content_object", None)
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
if obj.kind != TaskLinkedObjectKind.REQUEST or request_obj is None or not user:
|
||
return False
|
||
return user_can_access_employee_request(user, request_obj)
|
||
|
||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||
def get_object_url(self, obj):
|
||
request_obj = getattr(obj, "content_object", None)
|
||
if request_obj is None or not self.get_can_open(obj):
|
||
return None
|
||
return f"/requests?request={request_obj.id}"
|
||
|
||
|
||
def get_request_content_type():
|
||
return ContentType.objects.get_for_model(EmployeeRequest)
|
||
|
||
|
||
class TaskLinkedProcurementRequestSerializer(serializers.ModelSerializer):
|
||
created_by = EmployeeBriefSerializer(read_only=True)
|
||
procurement_request = serializers.SerializerMethodField()
|
||
procurement_request_id = serializers.IntegerField(
|
||
source="object_id", read_only=True
|
||
)
|
||
can_open = serializers.SerializerMethodField()
|
||
object_url = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = TaskLinkedObject
|
||
fields = [
|
||
"id",
|
||
"kind",
|
||
"procurement_request_id",
|
||
"procurement_request",
|
||
"can_open",
|
||
"object_url",
|
||
"created_by",
|
||
"created_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||
def get_procurement_request(self, obj):
|
||
if obj.kind != TaskLinkedObjectKind.PROCUREMENT_REQUEST:
|
||
return None
|
||
|
||
procurement_request = getattr(obj, "content_object", None)
|
||
if procurement_request is None:
|
||
return None
|
||
|
||
return {
|
||
"id": procurement_request.id,
|
||
"title": procurement_request.title,
|
||
"description": procurement_request.description,
|
||
"status": procurement_request.status,
|
||
"status_display": procurement_request.get_status_display(),
|
||
"urgency": procurement_request.urgency,
|
||
"urgency_display": procurement_request.get_urgency_display(),
|
||
"fulfillment_status": procurement_request.fulfillment_status,
|
||
"fulfillment_status_display": procurement_request.get_fulfillment_status_display(),
|
||
"department_id": procurement_request.department_id,
|
||
"department_name": (
|
||
procurement_request.department.name
|
||
if procurement_request.department_id
|
||
else None
|
||
),
|
||
"processing_department_id": procurement_request.processing_department_id,
|
||
"processing_department_name": (
|
||
procurement_request.processing_department.name
|
||
if procurement_request.processing_department_id
|
||
else None
|
||
),
|
||
"requestor": EmployeeBriefSerializer(procurement_request.requestor).data,
|
||
"executor": (
|
||
EmployeeBriefSerializer(procurement_request.executor).data
|
||
if procurement_request.executor_id
|
||
else None
|
||
),
|
||
"total_cost": procurement_request.total_cost,
|
||
"items_count": procurement_request.items_count,
|
||
"created_at": procurement_request.created_at,
|
||
"updated_at": procurement_request.updated_at,
|
||
}
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_open(self, obj):
|
||
procurement_request = getattr(obj, "content_object", None)
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
if (
|
||
obj.kind != TaskLinkedObjectKind.PROCUREMENT_REQUEST
|
||
or procurement_request is None
|
||
or not user
|
||
):
|
||
return False
|
||
return user_can_access_procurement_request(user, procurement_request)
|
||
|
||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||
def get_object_url(self, obj):
|
||
procurement_request = getattr(obj, "content_object", None)
|
||
if procurement_request is None or not self.get_can_open(obj):
|
||
return None
|
||
return f"/procurement?request={procurement_request.id}"
|
||
|
||
|
||
def get_procurement_request_content_type():
|
||
return ContentType.objects.get_for_model(ProcurementRequest)
|
||
|
||
|
||
class TaskLinkedEmployeeSerializer(serializers.ModelSerializer):
|
||
created_by = EmployeeBriefSerializer(read_only=True)
|
||
employee = serializers.SerializerMethodField()
|
||
employee_id = serializers.IntegerField(source="object_id", read_only=True)
|
||
can_open = serializers.SerializerMethodField()
|
||
object_url = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = TaskLinkedObject
|
||
fields = [
|
||
"id",
|
||
"kind",
|
||
"employee_id",
|
||
"employee",
|
||
"can_open",
|
||
"object_url",
|
||
"created_by",
|
||
"created_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||
def get_employee(self, obj):
|
||
if obj.kind != TaskLinkedObjectKind.EMPLOYEE:
|
||
return None
|
||
|
||
employee = getattr(obj, "content_object", None)
|
||
if employee is None:
|
||
return None
|
||
|
||
departments = []
|
||
links = getattr(employee, "dept_links", None)
|
||
if links is None:
|
||
links = EmployeeDepartment.objects.select_related(
|
||
"department",
|
||
"role",
|
||
).filter(employee=employee, is_active=True)
|
||
departments = [
|
||
{
|
||
"id": link.department_id,
|
||
"name": link.department.name if link.department_id else None,
|
||
"role_id": link.role_id,
|
||
"role_name": link.role.name if link.role_id else None,
|
||
"is_head": (
|
||
link.department.head_id == employee.id
|
||
if link.department_id
|
||
else False
|
||
),
|
||
}
|
||
for link in links
|
||
]
|
||
personnel_state = resolve_employee_personnel_state(
|
||
employee,
|
||
timezone.localdate(),
|
||
)
|
||
|
||
return {
|
||
**EmployeeBriefSerializer(employee).data,
|
||
"position": (
|
||
{
|
||
"id": employee.position_id,
|
||
"name": employee.position.name,
|
||
}
|
||
if employee.position_id
|
||
else None
|
||
),
|
||
"departments": departments,
|
||
"is_active": employee.is_active,
|
||
"personnel_state": {
|
||
"status": personnel_state.status,
|
||
"label": personnel_state.label or "Работает",
|
||
"action_id": personnel_state.action_id,
|
||
"date_from": personnel_state.date_from,
|
||
"date_to": personnel_state.date_to,
|
||
"expects_attendance": personnel_state.expects_attendance,
|
||
},
|
||
}
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_open(self, obj):
|
||
employee = getattr(obj, "content_object", None)
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
if obj.kind != TaskLinkedObjectKind.EMPLOYEE or employee is None or not user:
|
||
return False
|
||
return user_can_access_employee(user, employee)
|
||
|
||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||
def get_object_url(self, obj):
|
||
employee = getattr(obj, "content_object", None)
|
||
if employee is None or not self.get_can_open(obj):
|
||
return None
|
||
return f"/users/{employee.id}"
|
||
|
||
|
||
def get_employee_content_type():
|
||
return ContentType.objects.get_for_model(User)
|
||
|
||
|
||
def _guest_payload(guest):
|
||
return {
|
||
"id": guest.id,
|
||
"full_name": guest.full_name,
|
||
"last_name": guest.last_name,
|
||
"first_name": guest.first_name,
|
||
"patronymic": guest.patronymic,
|
||
"birth_date": guest.birth_date,
|
||
"phone": guest.phone,
|
||
"email": guest.email,
|
||
"avatar": guest.avatar.url if getattr(guest, "avatar", None) else None,
|
||
"organization": guest.organization,
|
||
"position": guest.position,
|
||
"is_active": guest.is_active,
|
||
"is_blacklisted": guest.is_blacklisted,
|
||
"ldap_username": guest.ldap_username,
|
||
"ldap_upn": guest.ldap_upn,
|
||
"created_at": guest.created_at,
|
||
"updated_at": guest.updated_at,
|
||
}
|
||
|
||
|
||
class TaskLinkedGuestSerializer(serializers.ModelSerializer):
|
||
created_by = EmployeeBriefSerializer(read_only=True)
|
||
guest = serializers.SerializerMethodField()
|
||
guest_id = serializers.IntegerField(source="object_id", read_only=True)
|
||
can_open = serializers.SerializerMethodField()
|
||
object_url = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = TaskLinkedObject
|
||
fields = [
|
||
"id",
|
||
"kind",
|
||
"guest_id",
|
||
"guest",
|
||
"can_open",
|
||
"object_url",
|
||
"created_by",
|
||
"created_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||
def get_guest(self, obj):
|
||
if obj.kind != TaskLinkedObjectKind.GUEST:
|
||
return None
|
||
|
||
guest = getattr(obj, "content_object", None)
|
||
if guest is None:
|
||
return None
|
||
return _guest_payload(guest)
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_open(self, obj):
|
||
guest = getattr(obj, "content_object", None)
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
if obj.kind != TaskLinkedObjectKind.GUEST or guest is None or not user:
|
||
return False
|
||
return user_can_access_guest(user, guest)
|
||
|
||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||
def get_object_url(self, obj):
|
||
guest = getattr(obj, "content_object", None)
|
||
if guest is None or not self.get_can_open(obj):
|
||
return None
|
||
return f"/guests?guest={guest.id}"
|
||
|
||
|
||
def get_guest_content_type():
|
||
return ContentType.objects.get_for_model(Guest)
|
||
|
||
|
||
class TaskLinkedGuestVisitSerializer(serializers.ModelSerializer):
|
||
created_by = EmployeeBriefSerializer(read_only=True)
|
||
guest_visit = serializers.SerializerMethodField()
|
||
guest_visit_id = serializers.IntegerField(source="object_id", read_only=True)
|
||
can_open = serializers.SerializerMethodField()
|
||
object_url = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = TaskLinkedObject
|
||
fields = [
|
||
"id",
|
||
"kind",
|
||
"guest_visit_id",
|
||
"guest_visit",
|
||
"can_open",
|
||
"object_url",
|
||
"created_by",
|
||
"created_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||
def get_guest_visit(self, obj):
|
||
if obj.kind != TaskLinkedObjectKind.GUEST_VISIT:
|
||
return None
|
||
|
||
visit = getattr(obj, "content_object", None)
|
||
if visit is None:
|
||
return None
|
||
|
||
return {
|
||
"id": visit.id,
|
||
"guest": _guest_payload(visit.guest),
|
||
"inviter": EmployeeBriefSerializer(visit.inviter).data,
|
||
"inviter_snapshot_name": visit.inviter_snapshot_name,
|
||
"inviter_snapshot_email": visit.inviter_snapshot_email,
|
||
"purpose": visit.purpose,
|
||
"visit_comment": visit.visit_comment,
|
||
"status": visit.status,
|
||
"status_display": visit.get_status_display(),
|
||
"access_starts_at": visit.access_starts_at,
|
||
"access_expires_at": visit.access_expires_at,
|
||
"all_day": visit.all_day,
|
||
"unlimited": visit.unlimited,
|
||
"submitted_at": visit.submitted_at,
|
||
"is_active_now": visit.is_active_now,
|
||
"is_expired": visit.is_expired,
|
||
"created_at": visit.created_at,
|
||
"updated_at": visit.updated_at,
|
||
}
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_open(self, obj):
|
||
visit = getattr(obj, "content_object", None)
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
if obj.kind != TaskLinkedObjectKind.GUEST_VISIT or visit is None or not user:
|
||
return False
|
||
return user_can_access_guest_visit(user, visit)
|
||
|
||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||
def get_object_url(self, obj):
|
||
visit = getattr(obj, "content_object", None)
|
||
if visit is None or not self.get_can_open(obj):
|
||
return None
|
||
return f"/guests?visit={visit.id}"
|
||
|
||
|
||
def get_guest_visit_content_type():
|
||
return ContentType.objects.get_for_model(GuestVisit)
|
||
|
||
|
||
class TaskLinkedAttendanceRecordSerializer(serializers.ModelSerializer):
|
||
created_by = EmployeeBriefSerializer(read_only=True)
|
||
attendance_record = serializers.SerializerMethodField()
|
||
attendance_record_id = serializers.IntegerField(source="object_id", read_only=True)
|
||
can_open = serializers.SerializerMethodField()
|
||
object_url = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = TaskLinkedObject
|
||
fields = [
|
||
"id",
|
||
"kind",
|
||
"attendance_record_id",
|
||
"attendance_record",
|
||
"can_open",
|
||
"object_url",
|
||
"created_by",
|
||
"created_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||
def get_attendance_record(self, obj):
|
||
if obj.kind != TaskLinkedObjectKind.ATTENDANCE_RECORD:
|
||
return None
|
||
|
||
record = getattr(obj, "content_object", None)
|
||
if record is None:
|
||
return None
|
||
|
||
return {
|
||
"id": record.id,
|
||
"employee_id": record.employee_id,
|
||
"employee": EmployeeBriefSerializer(record.employee).data,
|
||
"date": record.date,
|
||
"display_name": record.display_name,
|
||
"arrival_time": record.arrival_time,
|
||
"departure_time": record.departure_time,
|
||
"work_hours": record.work_hours,
|
||
"expected_hours": record.expected_hours,
|
||
"is_workday": record.is_workday,
|
||
"effective_is_workday": record.effective_is_workday,
|
||
"is_late": record.is_late,
|
||
"late_minutes": record.late_minutes,
|
||
"is_early_leave": record.is_early_leave,
|
||
"early_leave_minutes": record.early_leave_minutes,
|
||
"is_underwork": record.is_underwork,
|
||
"underwork_hours": record.underwork_hours,
|
||
"is_overtime": record.is_overtime,
|
||
"overtime_hours": record.overtime_hours,
|
||
"is_absent": record.is_absent,
|
||
"statuses": record.statuses,
|
||
"employee_issues": record.employee_issues,
|
||
"technical_issues": record.technical_issues,
|
||
"personnel_status": record.personnel_status,
|
||
"personnel_status_label": record.personnel_status_label,
|
||
"comments_count": get_comment_count(record),
|
||
"updated_at": record.updated_at,
|
||
}
|
||
|
||
@extend_schema_field(serializers.BooleanField())
|
||
def get_can_open(self, obj):
|
||
record = getattr(obj, "content_object", None)
|
||
request = self.context.get("request")
|
||
user = getattr(request, "user", None)
|
||
if (
|
||
obj.kind != TaskLinkedObjectKind.ATTENDANCE_RECORD
|
||
or record is None
|
||
or not user
|
||
):
|
||
return False
|
||
return user_can_access_attendance_record(user, record)
|
||
|
||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||
def get_object_url(self, obj):
|
||
record = getattr(obj, "content_object", None)
|
||
if record is None or not self.get_can_open(obj):
|
||
return None
|
||
return f"/attendance?record={record.id}"
|
||
|
||
|
||
def get_attendance_record_content_type():
|
||
return ContentType.objects.get_for_model(AttendanceRecord)
|
||
|
||
|
||
def _linked_object_cover_payload(link, context):
|
||
source = getattr(link, "content_object", None)
|
||
kind = link.kind
|
||
title = link.get_kind_display()
|
||
description = ""
|
||
image_url = None
|
||
|
||
if source is not None:
|
||
if kind == TaskLinkedObjectKind.POST:
|
||
title = source.title or "Новость"
|
||
description = source.body or ""
|
||
if source.image:
|
||
from api.v1.utils import build_media_url
|
||
|
||
image_url = build_media_url(source.image, context.get("request"))
|
||
elif kind == TaskLinkedObjectKind.MESSAGE:
|
||
title = source.author.get_full_name() or source.author.email or "Сообщение"
|
||
description = source.content or ""
|
||
elif kind == TaskLinkedObjectKind.CALENDAR_EVENT:
|
||
title = getattr(source, "title", None) or "Календарное событие"
|
||
description = getattr(source, "description", "") or ""
|
||
elif kind == TaskLinkedObjectKind.DOCUMENT:
|
||
title = source.title or "Документ"
|
||
description = source.description or ""
|
||
elif kind == TaskLinkedObjectKind.REQUEST:
|
||
title = source.display_title or source.title or "Заявление"
|
||
description = source.comment or ""
|
||
elif kind == TaskLinkedObjectKind.PROCUREMENT_REQUEST:
|
||
title = source.title or "Заявка на закупку"
|
||
description = source.description or ""
|
||
elif kind == TaskLinkedObjectKind.EMPLOYEE:
|
||
title = source.get_full_name() or source.email or "Сотрудник"
|
||
description = getattr(source, "position_name", "") or ""
|
||
elif kind == TaskLinkedObjectKind.GUEST:
|
||
title = source.full_name or "Гость"
|
||
description = source.organization or ""
|
||
elif kind == TaskLinkedObjectKind.GUEST_VISIT:
|
||
title = source.guest.full_name or "Гостевой визит"
|
||
description = source.purpose or ""
|
||
elif kind == TaskLinkedObjectKind.ATTENDANCE_RECORD:
|
||
title = source.display_name or "Запись посещаемости"
|
||
description = str(source.date)
|
||
|
||
return {
|
||
"id": link.id,
|
||
"kind": kind,
|
||
"kind_display": link.get_kind_display(),
|
||
"object_id": link.object_id,
|
||
"title": str(title),
|
||
"description": str(description),
|
||
"image_url": image_url,
|
||
}
|
||
|
||
|
||
class TaskCoverSerializer(serializers.ModelSerializer):
|
||
kind_display = serializers.CharField(source="get_kind_display", read_only=True)
|
||
attachment = serializers.SerializerMethodField()
|
||
checklist = serializers.SerializerMethodField()
|
||
comment = serializers.SerializerMethodField()
|
||
external_link = serializers.SerializerMethodField()
|
||
linked_object = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = TaskCover
|
||
fields = [
|
||
"id",
|
||
"kind",
|
||
"kind_display",
|
||
"attachment",
|
||
"checklist",
|
||
"comment",
|
||
"external_link",
|
||
"linked_object",
|
||
"updated_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||
def get_attachment(self, obj):
|
||
if obj.kind != TaskCoverType.ATTACHMENT or obj.attachment is None:
|
||
return None
|
||
return TaskAttachmentSerializer(
|
||
obj.attachment,
|
||
context=self.context,
|
||
).data
|
||
|
||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||
def get_checklist(self, obj):
|
||
if obj.kind != TaskCoverType.CHECKLIST:
|
||
return None
|
||
items = list(obj.task.checklist_items.all())
|
||
return {
|
||
"total": len(items),
|
||
"completed": sum(item.is_completed for item in items),
|
||
"items": [
|
||
{
|
||
"id": item.id,
|
||
"title": item.title,
|
||
"is_completed": item.is_completed,
|
||
}
|
||
for item in items[:6]
|
||
],
|
||
}
|
||
|
||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||
def get_comment(self, obj):
|
||
message = obj.comment
|
||
if obj.kind != TaskCoverType.COMMENT or message is None or message.is_deleted:
|
||
return None
|
||
attachments = [
|
||
attachment
|
||
for attachment in message.task_attachments.all()
|
||
if attachment.task_id == obj.task_id
|
||
]
|
||
return {
|
||
"id": message.id,
|
||
"author": EmployeeBriefSerializer(message.author).data,
|
||
"text": message.content,
|
||
"created_at": message.created_at,
|
||
"attachments": TaskAttachmentSerializer(
|
||
attachments,
|
||
many=True,
|
||
context=self.context,
|
||
).data,
|
||
}
|
||
|
||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||
def get_external_link(self, obj):
|
||
if obj.kind != TaskCoverType.EXTERNAL_LINK or obj.external_link is None:
|
||
return None
|
||
return TaskExternalLinkSerializer(
|
||
obj.external_link,
|
||
context=self.context,
|
||
).data
|
||
|
||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||
def get_linked_object(self, obj):
|
||
if obj.kind != TaskCoverType.LINKED_OBJECT or obj.linked_object is None:
|
||
return None
|
||
return _linked_object_cover_payload(obj.linked_object, self.context)
|