From 043f99a8fbeebd28e7644f248132c9e62d4207ce Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 10 Nov 2026 17:31:38 +0300 Subject: [PATCH] Add board-scoped checklist templates and publication approvals --- .../tasks/serializers/checklist_templates.py | 187 ++++- backend/api/v1/tasks/urls.py | 6 + backend/api/v1/tasks/viewsets/__init__.py | 6 +- backend/api/v1/tasks/viewsets/boards.py | 63 +- .../v1/tasks/viewsets/checklist_templates.py | 178 ++++- backend/tasks/checklist_template_access.py | 80 ++ ..._taskchecklisttemplate_options_and_more.py | 246 ++++++ ...0046_backfill_checklist_template_scopes.py | 29 + backend/tasks/models.py | 143 +++- .../tasks/test_checklist_template_scopes.py | 289 +++++++ docs/guides/API.md | 49 ++ frontend/src/app/tasks/page.tsx | 21 +- .../tasks/TaskChecklistTemplatesModal.tsx | 753 +++++++++++++----- frontend/src/lib/api/tasks.ts | 54 +- frontend/src/types/api.ts | 37 + 15 files changed, 1901 insertions(+), 240 deletions(-) create mode 100644 backend/tasks/checklist_template_access.py create mode 100644 backend/tasks/migrations/0045_alter_taskchecklisttemplate_options_and_more.py create mode 100644 backend/tasks/migrations/0046_backfill_checklist_template_scopes.py create mode 100644 backend/tests/api/v1/tasks/test_checklist_template_scopes.py diff --git a/backend/api/v1/tasks/serializers/checklist_templates.py b/backend/api/v1/tasks/serializers/checklist_templates.py index 0025564c..b6ff64d1 100644 --- a/backend/api/v1/tasks/serializers/checklist_templates.py +++ b/backend/api/v1/tasks/serializers/checklist_templates.py @@ -1,14 +1,44 @@ -"""Validated checklist templates (personal and shared).""" +"""Checklist templates are scoped, immutable snapshots when applied, and versioned.""" +from django.db import transaction from rest_framework import serializers -from tasks.access import task_board_access_q -from tasks.models import Task, TaskBoard, TaskChecklistTemplate +from tasks.checklist_template_access import ( + accessible_boards, + can_edit_template, + can_manage_board_templates, + can_manage_company_templates, +) +from tasks.models import ( + Task, + TaskBoard, + TaskChecklistTemplate, + TaskChecklistTemplateProposal, + TaskChecklistTemplateRevision, +) + + +def snapshot_revision(template, actor): + return TaskChecklistTemplateRevision.objects.create( + template=template, + changed_by=actor, + version=template.version, + name=template.name, + description=template.description, + items=list(template.items), + ) class TaskChecklistTemplateSerializer(serializers.ModelSerializer): created_by_id = serializers.IntegerField(read_only=True) source_task_id = serializers.IntegerField(write_only=True, required=False) + board_id = serializers.PrimaryKeyRelatedField( + source="board", + queryset=TaskBoard.objects.all(), + allow_null=True, + required=False, + ) + can_edit = serializers.SerializerMethodField() items = serializers.ListField( child=serializers.CharField(max_length=500, trim_whitespace=True), min_length=1, @@ -24,12 +54,28 @@ class TaskChecklistTemplateSerializer(serializers.ModelSerializer): "description", "created_by_id", "is_shared", + "scope", + "board_id", "items", "source_task_id", + "is_archived", + "version", + "can_edit", "created_at", "updated_at", ] - read_only_fields = ["id", "created_by_id", "created_at", "updated_at"] + read_only_fields = [ + "id", + "created_by_id", + "is_archived", + "version", + "can_edit", + "created_at", + "updated_at", + ] + + def get_can_edit(self, obj): + return can_edit_template(self.context["request"].user, obj) def validate(self, attrs): attrs = super().validate(attrs) @@ -42,7 +88,7 @@ class TaskChecklistTemplateSerializer(serializers.ModelSerializer): ) task = ( Task.objects.filter(pk=source_task_id, board__is_archived=False) - .filter(board__in=TaskBoard.objects.filter(task_board_access_q(user))) + .filter(board__in=accessible_boards(user)) .first() ) if task is None: @@ -54,23 +100,124 @@ class TaskChecklistTemplateSerializer(serializers.ModelSerializer): "title", flat=True ) ) - if self.instance is None and not attrs.get("items"): - raise serializers.ValidationError( - {"items": "Добавьте хотя бы один пункт чек-листа."} - ) + if self.instance is None: + scope = attrs.get("scope") + old_shared_flag = attrs.get("is_shared", False) + if scope is None: + scope = ( + TaskChecklistTemplate.Scope.COMPANY + if old_shared_flag + else TaskChecklistTemplate.Scope.PERSONAL + ) + if old_shared_flag and scope != TaskChecklistTemplate.Scope.COMPANY: + raise serializers.ValidationError( + {"is_shared": "Общий шаблон должен принадлежать компании."} + ) + board = attrs.get("board") + if scope == TaskChecklistTemplate.Scope.BOARD: + if board is None or not can_manage_board_templates(user, board): + raise serializers.ValidationError( + {"board_id": "Для публикации на доске нужны права редактора."} + ) + elif board is not None: + raise serializers.ValidationError( + {"board_id": "Доска указывается только для шаблонов доски."} + ) + if ( + scope == TaskChecklistTemplate.Scope.COMPANY + and not can_manage_company_templates(user) + ): + raise serializers.ValidationError( + { + "scope": "Публикация для компании доступна уполномоченным сотрудникам." + } + ) + attrs["scope"] = scope + attrs["is_shared"] = scope == TaskChecklistTemplate.Scope.COMPANY + if not attrs.get("items"): + raise serializers.ValidationError( + {"items": "Добавьте хотя бы один пункт чек-листа."} + ) + else: + if "scope" in attrs and attrs["scope"] != self.instance.scope: + raise serializers.ValidationError( + {"scope": "Переносите личный шаблон на доску через согласование."} + ) + if "board" in attrs and attrs["board"] != self.instance.board: + raise serializers.ValidationError( + {"board_id": "Нельзя изменять доску опубликованного шаблона."} + ) + if "is_shared" in attrs and attrs["is_shared"] != self.instance.is_shared: + raise serializers.ValidationError( + { + "is_shared": "Изменять область публикации через редактирование нельзя." + } + ) if "items" in attrs and not attrs["items"]: raise serializers.ValidationError( {"items": "Добавьте хотя бы один пункт чек-листа."} ) - if len(attrs.get("items", [])) > 100: - raise serializers.ValidationError( - {"items": "В одном шаблоне не больше 100 пунктов."} - ) - if attrs.get( - "is_shared", - self.instance.is_shared if self.instance else False, - ) and not (user.is_staff or user.is_superuser): - raise serializers.ValidationError( - {"is_shared": "Публикация общих шаблонов доступна администратору."} - ) return attrs + + @transaction.atomic + def create(self, validated_data): + user = self.context["request"].user + template = TaskChecklistTemplate.objects.create( + created_by=user, **validated_data + ) + snapshot_revision(template, user) + return template + + @transaction.atomic + def update(self, instance, validated_data): + changed = any( + getattr(instance, key) != value for key, value in validated_data.items() + ) + if changed: + for key, value in validated_data.items(): + setattr(instance, key, value) + instance.version += 1 + instance.save() + snapshot_revision(instance, self.context["request"].user) + return instance + + +class TaskChecklistTemplateProposalSerializer(serializers.ModelSerializer): + proposed_by_id = serializers.IntegerField(read_only=True) + reviewed_by_id = serializers.IntegerField(read_only=True) + published_template_id = serializers.IntegerField(read_only=True) + source_template_id = serializers.IntegerField(read_only=True) + board_id = serializers.IntegerField(read_only=True) + + class Meta: + model = TaskChecklistTemplateProposal + fields = [ + "id", + "board_id", + "source_template_id", + "proposed_by_id", + "name", + "description", + "items", + "status", + "reviewed_by_id", + "published_template_id", + "created_at", + "reviewed_at", + ] + read_only_fields = fields + + +class TaskChecklistTemplateRevisionSerializer(serializers.ModelSerializer): + changed_by_id = serializers.IntegerField(read_only=True) + + class Meta: + model = TaskChecklistTemplateRevision + fields = [ + "version", + "changed_by_id", + "name", + "description", + "items", + "created_at", + ] diff --git a/backend/api/v1/tasks/urls.py b/backend/api/v1/tasks/urls.py index 1d285e93..182d2e8a 100644 --- a/backend/api/v1/tasks/urls.py +++ b/backend/api/v1/tasks/urls.py @@ -6,6 +6,7 @@ from .viewsets import ( TaskBoardGroupViewSet, TaskBoardTemplateViewSet, TaskBoardViewSet, + TaskChecklistTemplateProposalViewSet, TaskChecklistTemplateViewSet, TaskColumnRowViewSet, TaskColumnViewSet, @@ -27,6 +28,11 @@ router.register( TaskChecklistTemplateViewSet, basename="task-checklist-template", ) +router.register( + r"checklist-template-proposals", + TaskChecklistTemplateProposalViewSet, + basename="task-checklist-template-proposal", +) router.register(r"columns", TaskColumnViewSet, basename="task-column") router.register(r"column-rows", TaskColumnRowViewSet, basename="task-column-row") router.register(r"labels", TaskLabelViewSet, basename="task-label") diff --git a/backend/api/v1/tasks/viewsets/__init__.py b/backend/api/v1/tasks/viewsets/__init__.py index fb0783b0..f919ce66 100644 --- a/backend/api/v1/tasks/viewsets/__init__.py +++ b/backend/api/v1/tasks/viewsets/__init__.py @@ -1,6 +1,9 @@ from .automation import TaskAutomationViewSet from .boards import TaskBoardGroupViewSet, TaskBoardViewSet -from .checklist_templates import TaskChecklistTemplateViewSet +from .checklist_templates import ( + TaskChecklistTemplateProposalViewSet, + TaskChecklistTemplateViewSet, +) from .structure import TaskColumnRowViewSet, TaskColumnViewSet, TaskLabelViewSet from .tasks import TaskViewSet from .templates import TaskBoardTemplateViewSet @@ -9,6 +12,7 @@ __all__ = [ "TaskAutomationViewSet", "TaskBoardGroupViewSet", "TaskChecklistTemplateViewSet", + "TaskChecklistTemplateProposalViewSet", "TaskBoardTemplateViewSet", "TaskBoardViewSet", "TaskColumnRowViewSet", diff --git a/backend/api/v1/tasks/viewsets/boards.py b/backend/api/v1/tasks/viewsets/boards.py index 849b31fe..8c8abb8f 100644 --- a/backend/api/v1/tasks/viewsets/boards.py +++ b/backend/api/v1/tasks/viewsets/boards.py @@ -15,12 +15,16 @@ from django.db.models import ( Value, ) from django.db.models.functions import Coalesce -from rest_framework import viewsets +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from communications.models import Message from employees.services.skill_progress import skill_queryset_with_rarity -from tasks.access import task_board_access_q +from tasks.access import task_board_access_q, user_can_access_task_board +from tasks.checklist_template_access import can_manage_board_templates from tasks.models import ( Task, TaskAttachment, @@ -28,6 +32,7 @@ from tasks.models import ( TaskBoardGroupMembership, TaskBoardPlacement, TaskChecklistItem, + TaskChecklistTemplateBoardEditor, TaskColumn, TaskCoverType, TaskExternalLink, @@ -54,6 +59,60 @@ class TaskBoardViewSet(TaskBoardActionsMixin, viewsets.ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = TaskBoardSerializer + @action(detail=True, methods=["get", "post"], url_path="checklist-template-editors") + def checklist_template_editors(self, request, pk=None): + board = self.get_object() + if request.method == "GET": + return Response( + { + "board_id": board.pk, + "can_manage": can_manage_board_templates(request.user, board), + "is_owner": board.created_by_id == request.user.pk, + "editor_ids": list( + TaskChecklistTemplateBoardEditor.objects.filter(board=board) + .order_by("user_id") + .values_list("user_id", flat=True) + ), + } + ) + if board.created_by_id != request.user.pk: + raise PermissionDenied( + "Назначать редакторов шаблонов может создатель доски." + ) + try: + editor_id = int(request.data.get("user_id")) + except (ValueError, TypeError): + return Response({"user_id": ["Выберите сотрудника."]}, status=400) + employee = get_user_model().objects.filter(id=editor_id, is_active=True).first() + if employee is None or not user_can_access_task_board(employee, board): + return Response( + {"user_id": ["Редактор должен быть активным участником доски."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + enabled = request.data.get("enabled") + if not isinstance(enabled, bool): + return Response({"enabled": ["Передайте true или false."]}, status=400) + if enabled: + TaskChecklistTemplateBoardEditor.objects.get_or_create( + board=board, + user=employee, + defaults={"appointed_by": request.user}, + ) + else: + TaskChecklistTemplateBoardEditor.objects.filter( + board=board, user=employee + ).delete() + return Response( + { + "board_id": board.pk, + "editor_ids": list( + TaskChecklistTemplateBoardEditor.objects.filter(board=board) + .order_by("user_id") + .values_list("user_id", flat=True) + ), + } + ) + def get_serializer_class(self): if self.action == "list" and self.request.query_params.get("view") == "summary": return TaskBoardSummarySerializer diff --git a/backend/api/v1/tasks/viewsets/checklist_templates.py b/backend/api/v1/tasks/viewsets/checklist_templates.py index be23cd88..386c3179 100644 --- a/backend/api/v1/tasks/viewsets/checklist_templates.py +++ b/backend/api/v1/tasks/viewsets/checklist_templates.py @@ -1,26 +1,38 @@ -"""Checklist template library and atomic insertion into an existing task.""" +"""Scoped checklist template library, board proposals, and safe insertion.""" from django.db import transaction from django.db.models import Max, Q from django.shortcuts import get_object_or_404 +from django.utils import timezone from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from tasks.access import task_board_access_q from tasks.activity import create_task_activity +from tasks.checklist_template_access import ( + accessible_boards, + can_edit_template, + can_manage_board_templates, + manageable_boards, + visible_templates, +) from tasks.models import ( Task, TaskActivityAction, - TaskBoard, TaskChecklistItem, TaskChecklistTemplate, + TaskChecklistTemplateProposal, ) from tasks.realtime import send_task_checklist_update -from ..serializers.checklist_templates import TaskChecklistTemplateSerializer +from ..serializers.checklist_templates import ( + TaskChecklistTemplateProposalSerializer, + TaskChecklistTemplateRevisionSerializer, + TaskChecklistTemplateSerializer, + snapshot_revision, +) from ..serializers.resources import TaskChecklistItemSerializer @@ -29,40 +41,92 @@ class TaskChecklistTemplateViewSet(viewsets.ModelViewSet): serializer_class = TaskChecklistTemplateSerializer def get_queryset(self): - return ( - TaskChecklistTemplate.objects.filter( - Q(created_by=self.request.user) | Q(is_shared=True) - ) - .select_related("created_by") - .distinct() + return visible_templates(self.request.user).select_related( + "created_by", "board" ) def get_object(self): template = super().get_object() if self.action in {"update", "partial_update", "destroy"}: - if template.created_by_id != self.request.user.pk: - raise PermissionDenied("Изменить шаблон может только автор.") + if not can_edit_template(self.request.user, template): + raise PermissionDenied( + "Изменить шаблон может только его ответственный." + ) return template - def perform_create(self, serializer): - serializer.save(created_by=self.request.user) + def perform_destroy(self, instance): + # Archive rather than delete board/company standards or their authorship. + instance.is_archived = True + instance.save(update_fields=["is_archived", "updated_at"]) + + @action(detail=True, methods=["get"], url_path="versions") + def versions(self, request, pk=None): + template = self.get_object() + return Response( + TaskChecklistTemplateRevisionSerializer( + template.revisions.all(), many=True + ).data + ) + + @action(detail=True, methods=["post"], url_path="propose") + def propose(self, request, pk=None): + template = self.get_object() + if ( + template.scope != TaskChecklistTemplate.Scope.PERSONAL + or template.created_by_id != request.user.pk + ): + raise PermissionDenied("Предложить можно только свой личный шаблон.") + try: + board_id = int(request.data.get("board_id")) + except (TypeError, ValueError): + return Response( + {"board_id": ["Выберите доску."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + board = get_object_or_404(accessible_boards(request.user), id=board_id) + with transaction.atomic(): + if TaskChecklistTemplateProposal.objects.filter( + source_template=template, + board=board, + status=TaskChecklistTemplateProposal.Status.PENDING, + ).exists(): + return Response( + {"board_id": ["Этот шаблон уже предложен для данной доски."]}, + status=status.HTTP_409_CONFLICT, + ) + proposal = TaskChecklistTemplateProposal.objects.create( + source_template=template, + board=board, + proposed_by=request.user, + name=template.name, + description=template.description, + items=list(template.items), + ) + return Response( + TaskChecklistTemplateProposalSerializer(proposal).data, + status=status.HTTP_201_CREATED, + ) @action(detail=True, methods=["post"], url_path="apply") def apply(self, request, pk=None): template = self.get_object() - raw_task_id = request.data.get("task_id") try: - task_id = int(raw_task_id) + task_id = int(request.data.get("task_id")) except (ValueError, TypeError): return Response( {"task_id": ["Укажите задачу для применения шаблона."]}, status=status.HTTP_400_BAD_REQUEST, ) allowed_tasks = Task.objects.filter(board__is_archived=False).filter( - board__in=TaskBoard.objects.filter(task_board_access_q(request.user)) + board__in=accessible_boards(request.user) ) with transaction.atomic(): task = get_object_or_404(allowed_tasks.select_for_update(), pk=task_id) + if ( + template.scope == TaskChecklistTemplate.Scope.BOARD + and task.board_id != template.board_id + ): + raise PermissionDenied("Этот шаблон опубликован для другой доски.") start = ( TaskChecklistItem.objects.filter(task=task).aggregate(Max("position"))[ "position__max" @@ -88,6 +152,7 @@ class TaskChecklistTemplateViewSet(viewsets.ModelViewSet): metadata={ "fields": ["checklist"], "template_id": template.id, + "template_version": template.version, "template_name": template.name, "added_items": len(created), }, @@ -101,3 +166,82 @@ class TaskChecklistTemplateViewSet(viewsets.ModelViewSet): }, status=status.HTTP_201_CREATED, ) + + +class TaskChecklistTemplateProposalViewSet(viewsets.GenericViewSet): + permission_classes = [IsAuthenticated] + serializer_class = TaskChecklistTemplateProposalSerializer + + def get_queryset(self): + return ( + TaskChecklistTemplateProposal.objects.filter( + Q(proposed_by=self.request.user) + | Q(board__in=manageable_boards(self.request.user)) + ) + .filter(board__in=accessible_boards(self.request.user)) + .select_related("board", "proposed_by", "source_template") + .distinct() + ) + + def list(self, request): + queryset = self.get_queryset() + board_id = request.query_params.get("board_id") + if board_id: + try: + queryset = queryset.filter(board_id=int(board_id)) + except (TypeError, ValueError): + return Response({"board_id": ["Неверная доска."]}, status=400) + status_filter = request.query_params.get("status") + if status_filter: + if status_filter not in TaskChecklistTemplateProposal.Status.values: + return Response({"status": ["Неверный статус."]}, status=400) + queryset = queryset.filter(status=status_filter) + page = self.paginate_queryset(queryset) + if page is not None: + return self.get_paginated_response( + self.get_serializer(page, many=True).data + ) + return Response(self.get_serializer(queryset, many=True).data) + + @action(detail=True, methods=["post"], url_path="review") + def review(self, request, pk=None): + decision = request.data.get("decision") + if decision not in ("approved", "rejected"): + return Response( + {"decision": ["Укажите approved или rejected."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + with transaction.atomic(): + proposal = get_object_or_404(self.get_queryset().select_for_update(), pk=pk) + if not can_manage_board_templates(request.user, proposal.board): + raise PermissionDenied( + "Только ответственный за доску утверждает шаблоны." + ) + if proposal.status != TaskChecklistTemplateProposal.Status.PENDING: + return Response( + {"decision": ["Это предложение уже рассмотрено."]}, + status=status.HTTP_409_CONFLICT, + ) + if decision == "approved": + published = TaskChecklistTemplate.objects.create( + name=proposal.name, + description=proposal.description, + created_by=proposal.proposed_by, + scope=TaskChecklistTemplate.Scope.BOARD, + board=proposal.board, + items=list(proposal.items), + ) + snapshot_revision(published, request.user) + proposal.published_template = published + proposal.status = decision + proposal.reviewed_by = request.user + proposal.reviewed_at = timezone.now() + proposal.save( + update_fields=[ + "status", + "reviewed_by", + "reviewed_at", + "published_template", + ] + ) + return Response(self.get_serializer(proposal).data) diff --git a/backend/tasks/checklist_template_access.py b/backend/tasks/checklist_template_access.py new file mode 100644 index 00000000..d237935f --- /dev/null +++ b/backend/tasks/checklist_template_access.py @@ -0,0 +1,80 @@ +"""Board-scoped checklist publication without granting unrelated task permissions.""" + +from django.db.models import Q + +from .access import task_board_access_q, user_can_access_task_board +from .models import ( + TaskBoard, + TaskChecklistTemplate, + TaskChecklistTemplateBoardEditor, +) + + +def accessible_boards(user): + return TaskBoard.objects.filter( + task_board_access_q(user), + is_archived=False, + ).distinct() + + +def can_manage_board_templates(user, board): + if not user or not user.is_authenticated or not user.is_active or board.is_archived: + return False + if not user_can_access_task_board(user, board): + return False + return ( + board.created_by_id == user.id + or TaskChecklistTemplateBoardEditor.objects.filter( + board_id=board.pk, user_id=user.pk + ).exists() + ) + + +def manageable_boards(user): + if not user or not user.is_authenticated or not user.is_active: + return TaskBoard.objects.none() + return ( + accessible_boards(user) + .filter(Q(created_by=user) | Q(checklist_template_editors__user=user)) + .distinct() + ) + + +def can_manage_company_templates(user): + return bool( + user + and user.is_authenticated + and user.is_active + and ( + user.is_staff + or user.is_superuser + or user.has_perm("tasks.manage_company_checklist_templates") + ) + ) + + +def visible_templates(user): + if not user or not user.is_authenticated or not user.is_active: + return TaskChecklistTemplate.objects.none() + return ( + TaskChecklistTemplate.objects.filter(is_archived=False) + .filter( + Q(scope=TaskChecklistTemplate.Scope.PERSONAL, created_by=user) + | Q( + scope=TaskChecklistTemplate.Scope.BOARD, + board__in=accessible_boards(user), + ) + | Q(scope=TaskChecklistTemplate.Scope.COMPANY) + ) + .distinct() + ) + + +def can_edit_template(user, template): + if template.scope == TaskChecklistTemplate.Scope.PERSONAL: + return template.created_by_id == user.pk and user.is_active + if template.scope == TaskChecklistTemplate.Scope.BOARD: + return template.board_id is not None and can_manage_board_templates( + user, template.board + ) + return can_manage_company_templates(user) diff --git a/backend/tasks/migrations/0045_alter_taskchecklisttemplate_options_and_more.py b/backend/tasks/migrations/0045_alter_taskchecklisttemplate_options_and_more.py new file mode 100644 index 00000000..64b00d17 --- /dev/null +++ b/backend/tasks/migrations/0045_alter_taskchecklisttemplate_options_and_more.py @@ -0,0 +1,246 @@ +# Generated by Django 5.2.4 on 2026-11-10 14:17 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0044_taskchecklisttemplate"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name="taskchecklisttemplate", + options={ + "ordering": ["name", "id"], + "permissions": [ + ( + "manage_company_checklist_templates", + "Управление корпоративными шаблонами чек-листов", + ) + ], + "verbose_name": "Шаблон чек-листа", + "verbose_name_plural": "Шаблоны чек-листов", + }, + ), + migrations.AddField( + model_name="taskchecklisttemplate", + name="board", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="checklist_templates", + to="tasks.taskboard", + verbose_name="Доска", + ), + ), + migrations.AddField( + model_name="taskchecklisttemplate", + name="is_archived", + field=models.BooleanField(default=False, verbose_name="В архиве"), + ), + migrations.AddField( + model_name="taskchecklisttemplate", + name="scope", + field=models.CharField( + choices=[ + ("personal", "Личный"), + ("board", "Доска"), + ("company", "Компания"), + ], + default="personal", + max_length=16, + verbose_name="Область публикации", + ), + ), + migrations.AddField( + model_name="taskchecklisttemplate", + name="version", + field=models.PositiveIntegerField(default=1, verbose_name="Версия"), + ), + migrations.AlterField( + model_name="taskchecklisttemplate", + name="created_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="task_checklist_templates", + to=settings.AUTH_USER_MODEL, + verbose_name="Автор", + ), + ), + migrations.CreateModel( + name="TaskChecklistTemplateProposal", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ("items", models.JSONField(default=list)), + ( + "status", + models.CharField( + choices=[ + ("pending", "Ожидает"), + ("approved", "Одобрено"), + ("rejected", "Отклонено"), + ], + default="pending", + max_length=16, + ), + ), + ("reviewed_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "board", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="checklist_template_proposals", + to="tasks.taskboard", + ), + ), + ( + "proposed_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="checklist_template_proposals", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "published_template", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="approved_proposals", + to="tasks.taskchecklisttemplate", + ), + ), + ( + "reviewed_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="reviewed_checklist_template_proposals", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "source_template", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="proposals", + to="tasks.taskchecklisttemplate", + ), + ), + ], + options={ + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="TaskChecklistTemplateBoardEditor", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "appointed_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="appointed_checklist_template_editors", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "board", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="checklist_template_editors", + to="tasks.taskboard", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="editable_task_checklist_template_boards", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("board", "user"), + name="uniq_checklist_editor_board_user", + ) + ], + }, + ), + migrations.CreateModel( + name="TaskChecklistTemplateRevision", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("version", models.PositiveIntegerField()), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ("items", models.JSONField(default=list)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "changed_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="checklist_template_revisions", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "template", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="revisions", + to="tasks.taskchecklisttemplate", + ), + ), + ], + options={ + "ordering": ["-version"], + "constraints": [ + models.UniqueConstraint( + fields=("template", "version"), + name="uniq_checklist_revision_version", + ) + ], + }, + ), + ] diff --git a/backend/tasks/migrations/0046_backfill_checklist_template_scopes.py b/backend/tasks/migrations/0046_backfill_checklist_template_scopes.py new file mode 100644 index 00000000..448265bc --- /dev/null +++ b/backend/tasks/migrations/0046_backfill_checklist_template_scopes.py @@ -0,0 +1,29 @@ +from django.db import migrations + + +def preserve_existing_shared_templates(apps, schema_editor): + Template = apps.get_model("tasks", "TaskChecklistTemplate") + Revision = apps.get_model("tasks", "TaskChecklistTemplateRevision") + db = schema_editor.connection.alias + Template.objects.using(db).filter(is_shared=True).update(scope="company") + for template in Template.objects.using(db).iterator(): + Revision.objects.using(db).get_or_create( + template_id=template.id, + version=template.version, + defaults={ + "changed_by_id": template.created_by_id, + "name": template.name, + "description": template.description, + "items": template.items, + }, + ) + + +class Migration(migrations.Migration): + dependencies = [("tasks", "0045_alter_taskchecklisttemplate_options_and_more")] + + operations = [ + migrations.RunPython( + preserve_existing_shared_templates, migrations.RunPython.noop + ) + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 35a10e6b..e5d2a384 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -1044,17 +1044,39 @@ class TaskBoardTemplate(models.Model): class TaskChecklistTemplate(models.Model): - """A reusable ordered checklist with no task-specific completion data.""" + """Personal, board-owned or company-owned reusable checklist snapshot.""" + + class Scope(models.TextChoices): + PERSONAL = "personal", "Личный" + BOARD = "board", "Доска" + COMPANY = "company", "Компания" name = models.CharField("Название", max_length=255) description = models.TextField("Описание", blank=True) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, + on_delete=models.PROTECT, related_name="task_checklist_templates", verbose_name="Автор", ) + scope = models.CharField( + "Область публикации", + max_length=16, + choices=Scope.choices, + default=Scope.PERSONAL, + ) + board = models.ForeignKey( + TaskBoard, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="checklist_templates", + verbose_name="Доска", + ) + # Kept for compatibility with the already deployed API and clients. is_shared = models.BooleanField("Общий", default=False) + is_archived = models.BooleanField("В архиве", default=False) + version = models.PositiveIntegerField("Версия", default=1) items = models.JSONField("Пункты", default=list) created_at = models.DateTimeField("Создано", auto_now_add=True) updated_at = models.DateTimeField("Обновлено", auto_now=True) @@ -1063,6 +1085,123 @@ class TaskChecklistTemplate(models.Model): verbose_name = "Шаблон чек-листа" verbose_name_plural = "Шаблоны чек-листов" ordering = ["name", "id"] + permissions = [ + ( + "manage_company_checklist_templates", + "Управление корпоративными шаблонами чек-листов", + ), + ] def __str__(self): return self.name + + +class TaskChecklistTemplateBoardEditor(models.Model): + """Explicitly appointed board template editor; not a board-wide task permission.""" + + board = models.ForeignKey( + TaskBoard, + on_delete=models.CASCADE, + related_name="checklist_template_editors", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="editable_task_checklist_template_boards", + ) + appointed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="appointed_checklist_template_editors", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["board", "user"], + name="uniq_checklist_editor_board_user", + ) + ] + + +class TaskChecklistTemplateProposal(models.Model): + """Submission snapshot; publication does not alter the author's template.""" + + class Status(models.TextChoices): + PENDING = "pending", "Ожидает" + APPROVED = "approved", "Одобрено" + REJECTED = "rejected", "Отклонено" + + source_template = models.ForeignKey( + TaskChecklistTemplate, + on_delete=models.SET_NULL, + related_name="proposals", + null=True, + blank=True, + ) + board = models.ForeignKey( + TaskBoard, + on_delete=models.CASCADE, + related_name="checklist_template_proposals", + ) + proposed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="checklist_template_proposals", + ) + name = models.CharField(max_length=255) + description = models.TextField(blank=True) + items = models.JSONField(default=list) + status = models.CharField( + max_length=16, + choices=Status.choices, + default=Status.PENDING, + ) + reviewed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="reviewed_checklist_template_proposals", + ) + reviewed_at = models.DateTimeField(null=True, blank=True) + published_template = models.ForeignKey( + TaskChecklistTemplate, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="approved_proposals", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at", "-id"] + + +class TaskChecklistTemplateRevision(models.Model): + """Immutable published template content and its editor at each version.""" + + template = models.ForeignKey( + TaskChecklistTemplate, + on_delete=models.CASCADE, + related_name="revisions", + ) + version = models.PositiveIntegerField() + changed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="checklist_template_revisions", + ) + name = models.CharField(max_length=255) + description = models.TextField(blank=True) + items = models.JSONField(default=list) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-version"] + constraints = [ + models.UniqueConstraint( + fields=["template", "version"], name="uniq_checklist_revision_version" + ) + ] diff --git a/backend/tests/api/v1/tasks/test_checklist_template_scopes.py b/backend/tests/api/v1/tasks/test_checklist_template_scopes.py new file mode 100644 index 00000000..2b80ae34 --- /dev/null +++ b/backend/tests/api/v1/tasks/test_checklist_template_scopes.py @@ -0,0 +1,289 @@ +"""Board checklist proposals, editor assignments, isolation and revision history.""" + +import pytest +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework.test import APIClient + +from tasks.models import ( + Task, + TaskBoard, + TaskChecklistTemplate, + TaskColumn, +) + +pytestmark = pytest.mark.django_db + + +def user(n, *, staff=False): + return get_user_model().objects.create_user( + email=f"scoped-template-{n}@example.com", + phone_number=f"+7999788{n:04d}", + password="testpass123", + first_name="Тест", + last_name="Шаблон", + is_active=True, + is_staff=staff, + email_verified=True, + send_activation_email=False, + ) + + +def client(person): + api = APIClient() + api.force_authenticate(user=person) + return api + + +def board(owner, name): + obj = TaskBoard.objects.create( + name=name, + created_by=owner, + access_scope="restricted", + ) + col = TaskColumn.objects.create(board=obj, name="Новое", position=1000) + return obj, Task.objects.create( + board=obj, column=col, title="Производство", created_by=owner + ) + + +def tpl_url(name="list", pk=None): + return reverse( + f"api:v1:tasks:task-checklist-template-{name}", + args=[pk] if pk is not None else None, + ) + + +def proposal_url(name="list", pk=None): + return reverse( + f"api:v1:tasks:task-checklist-template-proposal-{name}", + args=[pk] if pk is not None else None, + ) + + +def test_propose_and_approve_personal_copy_without_leaking_other_boards(): + owner, worker, stranger = user(1), user(2), user(3) + target, task = board(owner, "Сборка") + target.members.add(worker) + other, other_task = board(worker, "Соседняя") + owner_api, worker_api, stranger_api = ( + client(owner), + client(worker), + client(stranger), + ) + + created = worker_api.post( + tpl_url(), + {"name": "Сборка ретро", "items": ["Этап 1", "Этап 2"]}, + format="json", + ) + assert created.status_code == 201, created.data + original_id = created.data["id"] + assert created.data["scope"] == "personal" + + proposal = worker_api.post( + tpl_url("propose", original_id), + {"board_id": target.pk}, + format="json", + ) + assert proposal.status_code == 201, proposal.data + proposal_id = proposal.data["id"] + assert stranger_api.get(proposal_url()).data["count"] == 0 + assert owner_api.get(proposal_url()).data["count"] == 1 + assert worker_api.get(proposal_url()).data["count"] == 1 + + assert ( + worker_api.post( + proposal_url("review", proposal_id), + {"decision": "approved"}, + format="json", + ).status_code + == 403 + ) + assert ( + stranger_api.post( + proposal_url("review", proposal_id), + {"decision": "approved"}, + format="json", + ).status_code + == 404 + ) + + approved = owner_api.post( + proposal_url("review", proposal_id), + {"decision": "approved"}, + format="json", + ) + assert approved.status_code == 200, approved.data + published_id = approved.data["published_template_id"] + published = TaskChecklistTemplate.objects.get(pk=published_id) + assert published.scope == "board" + assert published.board_id == target.id + assert published.created_by_id == worker.id + assert published.version == 1 + assert published.revisions.count() == 1 + assert TaskChecklistTemplate.objects.get(pk=original_id).scope == "personal" + + assert stranger_api.get(tpl_url("detail", published_id)).status_code == 404 + assert worker_api.get(tpl_url("detail", published_id)).status_code == 200 + assert ( + worker_api.patch( + tpl_url("detail", published_id), + {"name": "Подмена"}, + format="json", + ).status_code + == 403 + ) + + applied = worker_api.post( + tpl_url("apply", published_id), + {"task_id": task.id}, + format="json", + ) + assert applied.status_code == 201, applied.data + assert [item.title for item in task.checklist_items.order_by("position")] == [ + "Этап 1", + "Этап 2", + ] + assert ( + worker_api.post( + tpl_url("apply", published_id), + {"task_id": other_task.id}, + format="json", + ).status_code + == 403 + ) + assert other_task.checklist_items.count() == 0 + + assert owner_api.delete(tpl_url("detail", published_id)).status_code == 204 + assert not owner_api.get(tpl_url()).data["results"] + assert task.checklist_items.count() == 2 + assert worker_api.get(tpl_url("detail", original_id)).status_code == 200 + + +def test_editor_can_publish_and_owner_can_revoke_without_granting_task_access(): + owner, editor, outsider = user(11), user(12), user(13, staff=True) + target, task = board(owner, "Закупка") + target.members.add(editor) + owner_api, editor_api, outsider_api = ( + client(owner), + client(editor), + client(outsider), + ) + editor_url = reverse( + "api:v1:tasks:task-board-checklist-template-editors", args=[target.pk] + ) + + assert ( + outsider_api.post( + editor_url, {"user_id": editor.pk, "enabled": True}, format="json" + ).status_code + == 404 + ) + assert ( + editor_api.post( + editor_url, {"user_id": editor.pk, "enabled": True}, format="json" + ).status_code + == 403 + ) + assert ( + owner_api.post( + editor_url, {"user_id": outsider.pk, "enabled": True}, format="json" + ).status_code + == 400 + ) + assert ( + editor_api.post( + tpl_url(), + { + "name": "Стандарт", + "items": ["Проверить"], + "scope": "board", + "board_id": target.pk, + }, + format="json", + ).status_code + == 400 + ) + + enabled = owner_api.post( + editor_url, {"user_id": editor.pk, "enabled": True}, format="json" + ) + assert enabled.status_code == 200, enabled.data + assert editor.pk in enabled.data["editor_ids"] + published = editor_api.post( + tpl_url(), + { + "name": "Стандарт", + "items": ["Проверить"], + "scope": "board", + "board_id": target.pk, + }, + format="json", + ) + assert published.status_code == 201, published.data + + changed = editor_api.patch( + tpl_url("detail", published.data["id"]), + {"items": ["Проверить", "Согласовать"]}, + format="json", + ) + assert changed.status_code == 200, changed.data + assert changed.data["version"] == 2 + versions = owner_api.get(tpl_url("versions", published.data["id"])) + assert versions.status_code == 200 + assert [rev["version"] for rev in versions.data] == [2, 1] + assert versions.data[1]["items"] == ["Проверить"] + assert versions.data[0]["items"] == ["Проверить", "Согласовать"] + + assert outsider_api.get(tpl_url("detail", published.data["id"])).status_code == 404 + assert ( + outsider_api.patch( + tpl_url("detail", published.data["id"]), + {"name": "Изменено"}, + format="json", + ).status_code + == 404 + ) + + disabled = owner_api.post( + editor_url, {"user_id": editor.pk, "enabled": False}, format="json" + ) + assert disabled.status_code == 200 + assert editor.pk not in disabled.data["editor_ids"] + assert ( + editor_api.patch( + tpl_url("detail", published.data["id"]), + {"name": "Переименовано"}, + format="json", + ).status_code + == 403 + ) + + +def test_company_template_outlives_author_role_and_is_readable_companywide(): + admin, worker, other_admin = user(21, staff=True), user(22), user(23, staff=True) + created = client(admin).post( + tpl_url(), + {"name": "Контроль", "items": ["Документы"], "scope": "company"}, + format="json", + ) + assert created.status_code == 201, created.data + template_id = created.data["id"] + assert created.data["is_shared"] is True + assert client(worker).get(tpl_url("detail", template_id)).status_code == 200 + assert ( + client(worker) + .patch(tpl_url("detail", template_id), {"name": "Подмена"}, format="json") + .status_code + == 403 + ) + assert ( + client(other_admin) + .patch( + tpl_url("detail", template_id), {"name": "Контроль качества"}, format="json" + ) + .status_code + == 200 + ) + assert TaskChecklistTemplate.objects.get(pk=template_id).created_by == admin diff --git a/docs/guides/API.md b/docs/guides/API.md index 3b831672..58b8f1e2 100644 --- a/docs/guides/API.md +++ b/docs/guides/API.md @@ -249,3 +249,52 @@ Titles are normalized and limited to 500 characters each. A template has Applying it adds uncompleted items to the end of the task checklist, transactionally, leaving existing items untouched. Applying it to a draft task from the frontend form adds draft items; persistence happens when the task is saved. + +### Three-level checklist template publication + +The checklist template library has a `scope`: `personal`, `board` or +`company`. Legacy `is_shared` remains in API responses for compatibility +and is true only for company-level templates. `board_id` is set only for +`board` scope. The user's board groups have no bearing on permissions. + +Board templates inherit the board's `private`, `restricted` and `all` +visibility rules. Their creators and explicitly appointed editors, **not all +board members**, may create/edit/archive board templates. Company publication +is available to staff, superusers or users with the +`tasks.manage_company_checklist_templates` permission. Company template +administrators do not automatically gain access to edit other boards' tasks. + +- `POST /api/v1/tasks/checklist-templates/` supports + `{"scope":"board","board_id":42,"name":"Сборка","items":["Этап 1"]}` only + for a board creator or its appointed template editor. +- `POST /api/v1/tasks/checklist-templates/{id}/propose/` with + `{"board_id":42}` snapshots an author's personal template into a board + proposal without changing that personal template. +- `GET /api/v1/tasks/checklist-template-proposals/?board_id=42` lists + proposals visible to their author or to that board's managers. +- `POST /api/v1/tasks/checklist-template-proposals/{id}/review/` with + `{"decision":"approved"}` or `{"decision":"rejected"}` is restricted + to board managers. Approving creates a distinct, board-owned template. +- `GET /api/v1/tasks/boards/{board_id}/checklist-template-editors/` + returns the currently appointed employee IDs and whether the caller + can manage templates. The board creator can send `POST` with + `{"user_id":123,"enabled":true}` to appoint an active employee with + access to that board, or `false` to revoke access. +- `GET /api/v1/tasks/checklist-templates/{id}/versions/` returns immutable + content snapshots and authors of revisions. Updating a template bumps + `version` but does not mutate any previously applied checklist. +- `DELETE /api/v1/tasks/checklist-templates/{id}/` archives the template + instead of destroying its history. + +A board template can only be applied to tasks on the same board. Personal +and company templates may be applied to tasks on any board to which the +caller has task access. Company publication is a separate privilege from +board template editing. Proposing the same personal template to multiple +boards creates separately approved, independently versioned board copies; +a single approval never publishes it to the whole company. + +Existing templates with `is_shared=true` are automatically migrated to +company scope with their content preserved. Authors use protected foreign +keys and do not vanish when their account is deactivated. Company/board +templates survive staff departure. Pending proposals remain snapshots even +if the original personal template changes or is archived. diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 7980eea2..04d9bfd2 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -9975,7 +9975,26 @@ function TasksPageContent() { isOpen={checklistTemplatesOpen} onClose={() => setChecklistTemplatesOpen(false)} userId={user?.id ?? null} - canPublish={Boolean(user?.auth?.is_staff || user?.auth?.is_superuser)} + canPublish={Boolean( + user?.auth?.is_staff || + user?.auth?.is_superuser || + user?.auth?.permissions?.includes( + "tasks.manage_company_checklist_templates", + ), + )} + boards={boards} + currentBoardId={ + checklistTemplateTarget === "form" + ? form.column + ? (activeColumns.find( + (column) => column.id === Number(form.column), + )?.board ?? + board?.id ?? + null) + : (board?.id ?? null) + : (board?.id ?? null) + } + editorCandidates={employees} taskId={checklistTemplateTarget === "view" ? viewTask?.id : null} draftTarget={checklistTemplateTarget === "form"} sourceItems={ diff --git a/frontend/src/components/tasks/TaskChecklistTemplatesModal.tsx b/frontend/src/components/tasks/TaskChecklistTemplatesModal.tsx index 41d2de24..3b93d086 100644 --- a/frontend/src/components/tasks/TaskChecklistTemplatesModal.tsx +++ b/frontend/src/components/tasks/TaskChecklistTemplatesModal.tsx @@ -1,24 +1,38 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { BookTemplate, Check, + ChevronDown, Loader2, Pencil, Plus, + Send, Trash2, + Users, } from "lucide-react"; import { Modal } from "@/components/ui"; import { apiClient } from "@/lib/api"; -import type { TaskChecklistTemplate } from "@/types/api"; +import type { + TaskBoardSummary, + TaskChecklistTemplate, + TaskChecklistTemplateBoardRights, + TaskChecklistTemplateProposal, + TaskChecklistTemplateRevision, + TaskChecklistTemplateScope, + User, +} from "@/types/api"; type Props = { isOpen: boolean; onClose: () => void; userId: number | null; canPublish: boolean; + boards: TaskBoardSummary[]; + currentBoardId: number | null; + editorCandidates: User[]; taskId?: number | null; draftTarget?: boolean; sourceItems?: string; @@ -26,15 +40,28 @@ type Props = { onApplyDraft?: (items: string[]) => void; }; -function message(error: unknown, fallback: string): string { +function message(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } +function unwrap(value: T[] | { results: T[] }): T[] { + return Array.isArray(value) ? value : value.results; +} + +const tabs: { scope: TaskChecklistTemplateScope; label: string }[] = [ + { scope: "personal", label: "Мои" }, + { scope: "board", label: "Доски" }, + { scope: "company", label: "Компания" }, +]; + export default function TaskChecklistTemplatesModal({ isOpen, onClose, userId, canPublish, + boards, + currentBoardId, + editorCandidates, taskId = null, draftTarget = false, sourceItems = "", @@ -42,6 +69,14 @@ export default function TaskChecklistTemplatesModal({ onApplyDraft, }: Props) { const [templates, setTemplates] = useState([]); + const [proposals, setProposals] = useState( + [], + ); + const [rights, setRights] = useState( + null, + ); + const [boardId, setBoardId] = useState(null); + const [tab, setTab] = useState("personal"); const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -49,61 +84,124 @@ export default function TaskChecklistTemplatesModal({ const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [itemsText, setItemsText] = useState(""); - const [isShared, setIsShared] = useState(false); + const [newEditorId, setNewEditorId] = useState(null); + const [versions, setVersions] = useState< + Record + >({}); + const [openVersions, setOpenVersions] = useState(null); + + const loadTemplates = async () => { + const result = await apiClient.getTaskChecklistTemplates(); + setTemplates(unwrap(result)); + }; useEffect(() => { if (!isOpen) return; - let active = true; + let live = true; + setBoardId(currentBoardId ?? boards[0]?.id ?? null); + setTab("personal"); setName(""); setDescription(""); setItemsText(sourceItems); - setIsShared(false); setEditingId(null); setError(null); + setVersions({}); + setOpenVersions(null); setLoading(true); - void apiClient.getTaskChecklistTemplates().then( - (result) => { - if (!active) return; - setTemplates(Array.isArray(result) ? result : result.results); - setLoading(false); - }, - (cause: unknown) => { - if (!active) return; - setError(message(cause, "Не удалось загрузить библиотеку чек-листов")); - setLoading(false); - }, - ); + void apiClient + .getTaskChecklistTemplates() + .then( + (result) => { + if (live) setTemplates(unwrap(result)); + }, + (cause: unknown) => { + if (live) setError(message(cause, "Не удалось загрузить библиотеку")); + }, + ) + .finally(() => { + if (live) setLoading(false); + }); return () => { - active = false; + live = false; }; - }, [isOpen, sourceItems]); + }, [isOpen, sourceItems, currentBoardId, boards]); + + useEffect(() => { + if (!isOpen || !boardId) { + setRights(null); + setProposals([]); + return; + } + let live = true; + setRights(null); + setProposals([]); + void Promise.all([ + apiClient.getTaskChecklistTemplateBoardRights(boardId), + apiClient.getTaskChecklistTemplateProposals(boardId), + ]) + .then(([permissions, proposed]) => { + if (!live) return; + setRights(permissions); + setProposals(unwrap(proposed)); + }) + .catch((cause: unknown) => { + if (live) + setError(message(cause, "Не удалось загрузить доступы доски")); + }); + return () => { + live = false; + }; + }, [isOpen, boardId]); + + const displayed = useMemo( + () => + templates.filter((template) => { + if (template.scope !== tab) return false; + if (tab === "board") return template.board_id === boardId; + if (tab === "personal") return template.created_by_id === userId; + return true; + }), + [templates, tab, boardId, userId], + ); + + const canCreateInTab = + tab === "personal" || + (tab === "board" && Boolean(boardId && rights?.can_manage)) || + (tab === "company" && canPublish); + + const canApply = draftTarget || taskId !== null; + const itemTitles = itemsText + .split("\n") + .map((value) => value.trim()) + .filter(Boolean); const resetEditor = () => { setEditingId(null); setName(""); setDescription(""); setItemsText(sourceItems); - setIsShared(false); }; const save = async () => { - const titles = itemsText - .split("\n") - .map((title) => title.trim()) - .filter(Boolean); - if (busy || !name.trim() || !titles.length || titles.length > 100) return; - setError(null); + if (busy || !name.trim() || !itemTitles.length || itemTitles.length > 100) + return; + if (!editingId && !canCreateInTab) return; setBusy(true); + setError(null); try { - const payload = { - name: name.trim(), - description: description.trim(), - items: titles, - is_shared: canPublish && isShared, - }; const saved = editingId - ? await apiClient.updateTaskChecklistTemplate(editingId, payload) - : await apiClient.createTaskChecklistTemplate(payload); + ? await apiClient.updateTaskChecklistTemplate(editingId, { + name: name.trim(), + description: description.trim(), + items: itemTitles, + }) + : await apiClient.createTaskChecklistTemplate({ + name: name.trim(), + description: description.trim(), + items: itemTitles, + scope: tab, + ...(tab === "board" ? { board_id: boardId } : {}), + }); setTemplates((current) => [...current.filter((item) => item.id !== saved.id), saved].sort( (a, b) => a.name.localeCompare(b.name), @@ -118,16 +216,18 @@ export default function TaskChecklistTemplatesModal({ }; const edit = (template: TaskChecklistTemplate) => { + setTab(template.scope); + if (template.board_id !== null) setBoardId(template.board_id); setEditingId(template.id); setName(template.name); setDescription(template.description); setItemsText(template.items.join("\n")); - setIsShared(template.is_shared); setError(null); }; - const remove = async (template: TaskChecklistTemplate) => { - if (busy || !window.confirm(`Удалить шаблон «${template.name}»?`)) return; + const archive = async (template: TaskChecklistTemplate) => { + if (busy || !window.confirm(`Архивировать шаблон «${template.name}»?`)) + return; setBusy(true); setError(null); try { @@ -137,7 +237,7 @@ export default function TaskChecklistTemplatesModal({ ); if (editingId === template.id) resetEditor(); } catch (cause) { - setError(message(cause, "Не удалось удалить шаблон")); + setError(message(cause, "Не удалось архивировать шаблон")); } finally { setBusy(false); } @@ -145,6 +245,10 @@ export default function TaskChecklistTemplatesModal({ const apply = async (template: TaskChecklistTemplate) => { if (busy) return; + if (template.scope === "board" && template.board_id !== currentBoardId) { + setError("Этот шаблон предназначен для другой доски."); + return; + } setBusy(true); setError(null); try { @@ -158,203 +262,470 @@ export default function TaskChecklistTemplatesModal({ } onClose(); } catch (cause) { - setError(message(cause, "Не удалось добавить пункты чек-листа")); + setError(message(cause, "Не удалось применить чек-лист")); } finally { setBusy(false); } }; - const numberOfItems = itemsText - .split("\n") - .map((line) => line.trim()) - .filter(Boolean).length; - const canApply = draftTarget || taskId !== null; + const propose = async (template: TaskChecklistTemplate) => { + if (!boardId || busy) return; + setBusy(true); + setError(null); + try { + const proposal = await apiClient.proposeTaskChecklistTemplate( + template.id, + boardId, + ); + setProposals((current) => [proposal, ...current]); + setTab("board"); + } catch (cause) { + setError(message(cause, "Не удалось отправить предложение")); + } finally { + setBusy(false); + } + }; + + const review = async ( + proposal: TaskChecklistTemplateProposal, + decision: "approved" | "rejected", + ) => { + setBusy(true); + setError(null); + try { + const changed = await apiClient.reviewTaskChecklistTemplateProposal( + proposal.id, + decision, + ); + setProposals((current) => + current.map((value) => (value.id === changed.id ? changed : value)), + ); + await loadTemplates(); + } catch (cause) { + setError(message(cause, "Не удалось рассмотреть предложение")); + } finally { + setBusy(false); + } + }; + + const setEditor = async (editorId: number, enabled: boolean) => { + if (!boardId || busy) return; + setBusy(true); + setError(null); + try { + const response = await apiClient.setTaskChecklistTemplateBoardEditor( + boardId, + editorId, + enabled, + ); + setRights((current) => + current ? { ...current, editor_ids: response.editor_ids } : null, + ); + setNewEditorId(null); + } catch (cause) { + setError(message(cause, "Не удалось изменить редактора")); + } finally { + setBusy(false); + } + }; + + const toggleVersions = async (template: TaskChecklistTemplate) => { + if (openVersions === template.id) { + setOpenVersions(null); + return; + } + setOpenVersions(template.id); + if (versions[template.id]) return; + try { + const revisions = await apiClient.getTaskChecklistTemplateVersions( + template.id, + ); + setVersions((current) => ({ ...current, [template.id]: revisions })); + } catch (cause) { + setError(message(cause, "Не удалось получить версии шаблона")); + } + }; + + const userLabel = (id: number) => { + const user = editorCandidates.find((person) => person.id === id); + return user + ? [user.first_name, user.last_name].filter(Boolean).join(" ") + : `Сотрудник #${id}`; + }; return ( -
-
-
-

- {editingId ? "Редактировать шаблон" : "Новый шаблон чек-листа"} -

-

- Один пункт в каждой строке. Сохраняются только названия, без - отметок выполнения. Личные шаблоны видны только вам, общие - доступны всем. -

-
- setName(event.target.value)} - /> -