Corp/backend/api/v1/tasks/serializers.py

1848 lines
64 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
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):
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_staff or user.is_superuser))
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_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_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_attachment",
"checklist_total",
"checklist_completed",
"created_at",
"updated_at",
]
@extend_schema_field(serializers.IntegerField())
def get_linked_posts_count(self, obj):
if hasattr(obj, "linked_posts_count"):
return obj.linked_posts_count
return obj.linked_objects.filter(
kind=TaskLinkedObjectKind.POST,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_messages_count(self, obj):
if hasattr(obj, "linked_messages_count"):
return obj.linked_messages_count
return obj.linked_objects.filter(
kind=TaskLinkedObjectKind.MESSAGE,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_events_count(self, obj):
if hasattr(obj, "linked_events_count"):
return obj.linked_events_count
return obj.linked_objects.filter(
kind=TaskLinkedObjectKind.CALENDAR_EVENT,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_documents_count(self, obj):
if hasattr(obj, "linked_documents_count"):
return obj.linked_documents_count
return obj.linked_objects.filter(
kind=TaskLinkedObjectKind.DOCUMENT,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_requests_count(self, obj):
if hasattr(obj, "linked_requests_count"):
return obj.linked_requests_count
return obj.linked_objects.filter(
kind=TaskLinkedObjectKind.REQUEST,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_procurement_requests_count(self, obj):
if hasattr(obj, "linked_procurement_requests_count"):
return obj.linked_procurement_requests_count
return obj.linked_objects.filter(
kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_employees_count(self, obj):
if hasattr(obj, "linked_employees_count"):
return obj.linked_employees_count
return obj.linked_objects.filter(
kind=TaskLinkedObjectKind.EMPLOYEE,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_guests_count(self, obj):
if hasattr(obj, "linked_guests_count"):
return obj.linked_guests_count
return obj.linked_objects.filter(
kind=TaskLinkedObjectKind.GUEST,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_guest_visits_count(self, obj):
if hasattr(obj, "linked_guest_visits_count"):
return obj.linked_guest_visits_count
return obj.linked_objects.filter(
kind=TaskLinkedObjectKind.GUEST_VISIT,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_attendance_records_count(self, obj):
if hasattr(obj, "linked_attendance_records_count"):
return obj.linked_attendance_records_count
return obj.linked_objects.filter(
kind=TaskLinkedObjectKind.ATTENDANCE_RECORD,
).count()
@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()
@extend_schema_field(serializers.DictField(allow_null=True))
def get_cover_attachment(self, obj):
cover_attachments = getattr(obj, "cover_attachments", None)
if cover_attachments is not None:
attachment = cover_attachments[0] if cover_attachments else None
else:
prefetched = getattr(obj, "_prefetched_objects_cache", {}).get(
"attachments"
)
if prefetched is not None:
attachment = next(
(item for item in prefetched if item.is_cover),
None,
)
else:
attachment = obj.attachments.filter(is_cover=True).first()
if attachment is None:
return None
return TaskAttachmentSerializer(
attachment,
context=self.context,
).data
@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()
class Meta:
model = TaskAttachment
fields = [
"id",
"task",
"comment",
"file_name",
"file_size",
"mime_type",
"is_cover",
"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()
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)