from django.contrib.auth import get_user_model from django.db.models import Max from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from api.v1.employees.serializers import EmployeeBriefSerializer, SkillSerializer from communications.comments_helpers import get_comment_count from employees.models import Skill from tasks.access import ( user_can_access_task_board, ) from tasks.models import ( Task, TaskCover, TaskCoverType, TaskLabel, TaskLinkedObjectKind, ) from .linked import TaskCoverSerializer from .structure import TaskLabelSerializer User = get_user_model() 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", # Existing tasks may still contain dismissed employees. Resolve them here # and distinguish retained historical participants from newly added ones # in validate(), otherwise any edit that resubmits the participant list # becomes impossible after an employee is deactivated. queryset=User.objects.all(), 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, ) skills = SkillSerializer(many=True, read_only=True) skill_ids = serializers.PrimaryKeyRelatedField( source="skills", queryset=Skill.objects.filter(is_secret=False), 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", "skills", "skill_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) ) 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": "Дорожка должна принадлежать основной колонке задачи."} ) self._validate_task_users(attrs, board) return attrs def _validate_task_users(self, attrs, board): existing_assignee_id = ( self.instance.assignee_id if self.instance is not None else None ) board_changed = bool( board and (self.instance is None or self.instance.board_id != board.id) ) assignee = attrs.get("assignee") if "assignee" in attrs and assignee is not None: assignee_changed = assignee.id != existing_assignee_id if assignee_changed and ( not assignee.is_active or assignee.is_hidden_in_ui ): raise serializers.ValidationError( { "assignee_id": ( "Исполнителем можно выбрать только доступного " "активного сотрудника." ) } ) should_check_assignee_access = assignee_changed or ( board_changed and assignee.is_active ) if should_check_assignee_access and not user_can_access_task_board( assignee, board ): raise serializers.ValidationError( { "assignee_id": ( "Исполнителем может быть только сотрудник с доступом " "к этой доске." ) } ) participants = attrs.get("participants") if participants is not None: existing_participant_ids = ( set(self.instance.participants.values_list("id", flat=True)) if self.instance is not None else set() ) if self.instance is not None: existing_participant_ids.add(self.instance.created_by_id) if existing_assignee_id: existing_participant_ids.add(existing_assignee_id) new_participants = [ participant for participant in participants if participant.id not in existing_participant_ids ] if any( not participant.is_active or participant.is_hidden_in_ui for participant in new_participants ): raise serializers.ValidationError( { "participant_ids": ( "Добавлять в задачу можно только доступных " "активных сотрудников." ) } ) participants_to_check = ( [participant for participant in participants if participant.is_active] if board_changed else new_participants ) if board and any( not user_can_access_task_board(participant, board) for participant in participants_to_check ): raise serializers.ValidationError( { "participant_ids": ( "Участниками могут быть только сотрудники с доступом " "к этой доске." ) } ) def create(self, validated_data): labels = validated_data.pop("labels", []) skills = validated_data.pop("skills", []) 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) if skills: task.skills.set(skills) return task def update(self, instance, validated_data): previous_assignee_id = instance.assignee_id labels = validated_data.pop("labels", None) skills = validated_data.pop("skills", 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) if skills is not None: task.skills.set(skills) return task