Add board-scoped checklist templates and publication approvals
This commit is contained in:
parent
3b00174ad4
commit
043f99a8fb
@ -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",
|
||||
]
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
80
backend/tasks/checklist_template_access.py
Normal file
80
backend/tasks/checklist_template_access.py
Normal file
@ -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)
|
||||
@ -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",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
)
|
||||
]
|
||||
@ -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"
|
||||
)
|
||||
]
|
||||
|
||||
289
backend/tests/api/v1/tasks/test_checklist_template_scopes.py
Normal file
289
backend/tests/api/v1/tasks/test_checklist_template_scopes.py
Normal file
@ -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
|
||||
@ -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.
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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<T>(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<TaskChecklistTemplate[]>([]);
|
||||
const [proposals, setProposals] = useState<TaskChecklistTemplateProposal[]>(
|
||||
[],
|
||||
);
|
||||
const [rights, setRights] = useState<TaskChecklistTemplateBoardRights | null>(
|
||||
null,
|
||||
);
|
||||
const [boardId, setBoardId] = useState<number | null>(null);
|
||||
const [tab, setTab] = useState<TaskChecklistTemplateScope>("personal");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<number | null>(null);
|
||||
const [versions, setVersions] = useState<
|
||||
Record<number, TaskChecklistTemplateRevision[]>
|
||||
>({});
|
||||
const [openVersions, setOpenVersions] = useState<number | null>(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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Шаблоны чек-листов"
|
||||
title="Библиотека чек-листов"
|
||||
size="lg"
|
||||
stackLevel={1}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-3 rounded-xl border border-[var(--border-subtle)] p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{editingId ? "Редактировать шаблон" : "Новый шаблон чек-листа"}
|
||||
</h3>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Один пункт в каждой строке. Сохраняются только названия, без
|
||||
отметок выполнения. Личные шаблоны видны только вам, общие
|
||||
доступны всем.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
aria-label="Название шаблона чек-листа"
|
||||
maxLength={255}
|
||||
placeholder="Название, например: Проверка оборудования"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
aria-label="Пункты шаблона чек-листа"
|
||||
placeholder={
|
||||
"Проверить комплектность\nУбедиться в исправности\nПодписать акт"
|
||||
}
|
||||
rows={5}
|
||||
value={itemsText}
|
||||
onChange={(event) => setItemsText(event.target.value)}
|
||||
/>
|
||||
<p className="app-text-muted text-xs">
|
||||
{numberOfItems} / 100 пунктов
|
||||
</p>
|
||||
<textarea
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
aria-label="Описание шаблона"
|
||||
placeholder="Описание (необязательно)"
|
||||
value={description}
|
||||
rows={2}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
{canPublish ? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isShared}
|
||||
onChange={(event) => setIsShared(event.target.checked)}
|
||||
/>
|
||||
Общий шаблон для всех сотрудников
|
||||
</label>
|
||||
) : (
|
||||
<p className="app-text-muted text-xs">
|
||||
Публикация в общей библиотеке доступна администраторам.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="tablist"
|
||||
aria-label="Раздел библиотеки"
|
||||
>
|
||||
{tabs.map(({ scope, label }) => (
|
||||
<button
|
||||
key={scope}
|
||||
type="button"
|
||||
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
|
||||
onClick={() => void save()}
|
||||
disabled={
|
||||
busy || !name.trim() || numberOfItems < 1 || numberOfItems > 100
|
||||
}
|
||||
role="tab"
|
||||
aria-selected={tab === scope}
|
||||
onClick={() => {
|
||||
setTab(scope);
|
||||
resetEditor();
|
||||
}}
|
||||
className={`rounded-lg px-3 py-2 text-sm font-medium ${tab === scope ? "app-action-primary" : "app-action-secondary"}`}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : editingId ? (
|
||||
<Check size={15} />
|
||||
) : (
|
||||
<Plus size={15} />
|
||||
)}
|
||||
{editingId ? "Сохранить изменения" : "Сохранить шаблон"}
|
||||
{label}
|
||||
</button>
|
||||
{editingId ? (
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "board" || tab === "personal" ? (
|
||||
<label className="block">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">
|
||||
Доска {tab === "personal" ? "для предложения шаблона" : ""}
|
||||
</span>
|
||||
<select
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
value={boardId ?? ""}
|
||||
onChange={(event) => {
|
||||
setBoardId(Number(event.target.value) || null);
|
||||
resetEditor();
|
||||
}}
|
||||
aria-label="Выберите доску"
|
||||
>
|
||||
{boards.map((board) => (
|
||||
<option key={board.id} value={board.id}>
|
||||
{board.name}
|
||||
</option>
|
||||
))}
|
||||
{!boards.length ? (
|
||||
<option value="">Нет доступных досок</option>
|
||||
) : null}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{canCreateInTab || editingId !== null ? (
|
||||
<section className="space-y-3 rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{editingId
|
||||
? "Изменить шаблон"
|
||||
: `Новый ${tab === "personal" ? "личный" : tab === "board" ? "шаблон доски" : "корпоративный"} шаблон`}
|
||||
</h3>
|
||||
<input
|
||||
aria-label="Название шаблона чек-листа"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Название шаблона"
|
||||
maxLength={255}
|
||||
/>
|
||||
<textarea
|
||||
aria-label="Пункты шаблона чек-листа"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
value={itemsText}
|
||||
onChange={(event) => setItemsText(event.target.value)}
|
||||
placeholder={
|
||||
"Подготовить оборудование\nВыполнить проверку\nПодписать акт"
|
||||
}
|
||||
rows={5}
|
||||
/>
|
||||
<p className="app-text-muted text-xs">
|
||||
{itemTitles.length} из 100 пунктов · один пункт в строке
|
||||
</p>
|
||||
<input
|
||||
aria-label="Описание шаблона"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="Описание (необязательно)"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetEditor}
|
||||
disabled={busy}
|
||||
className="app-action-secondary rounded-lg px-3 py-2 text-sm"
|
||||
className="app-action-primary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm disabled:opacity-50"
|
||||
onClick={() => void save()}
|
||||
disabled={
|
||||
busy ||
|
||||
!name.trim() ||
|
||||
!itemTitles.length ||
|
||||
itemTitles.length > 100
|
||||
}
|
||||
>
|
||||
Отмена
|
||||
{busy ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
<Plus size={15} />
|
||||
)}
|
||||
{editingId ? "Сохранить изменения" : "Сохранить шаблон"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
{editingId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetEditor}
|
||||
className="app-action-secondary rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<p className="app-text-muted text-xs">
|
||||
{tab === "board"
|
||||
? "Публиковать здесь может создатель доски или назначенный редактор. Остальные сотрудники предлагают личные шаблоны."
|
||||
: "Публикация корпоративных шаблонов доступна администраторам и уполномоченным сотрудникам."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Библиотека чек-листов</h3>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{tab === "personal"
|
||||
? "Мои шаблоны"
|
||||
: tab === "board"
|
||||
? "Шаблоны доски"
|
||||
: "Стандарты компании"}
|
||||
</h3>
|
||||
{loading ? (
|
||||
<p className="app-text-muted flex items-center gap-2 py-3 text-sm">
|
||||
<p className="app-text-muted flex items-center gap-2 text-sm">
|
||||
<Loader2 size={16} className="animate-spin" /> Загрузка...
|
||||
</p>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="app-text-muted rounded-lg border border-[var(--border-subtle)] p-4 text-sm">
|
||||
Шаблонов пока нет. Создайте первый из пунктов выше.
|
||||
) : displayed.length === 0 ? (
|
||||
<p className="app-text-muted rounded-lg border border-[var(--border-subtle)] p-3 text-sm">
|
||||
Пока нет опубликованных шаблонов.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{templates.map((template) => (
|
||||
displayed.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="rounded-lg border border-[var(--border-subtle)] p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BookTemplate size={16} className="app-text-muted shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
{template.name}
|
||||
</span>
|
||||
<span className="app-text-muted text-xs">
|
||||
v{template.version}
|
||||
</span>
|
||||
</div>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
{template.items.length} пунктов
|
||||
{template.description ? ` · ${template.description}` : ""}
|
||||
</p>
|
||||
<details className="mt-2">
|
||||
<summary className="app-text-muted cursor-pointer text-xs">
|
||||
Пункты чек-листа
|
||||
</summary>
|
||||
<ol className="app-text-muted mt-2 list-decimal space-y-1 pl-5 text-xs">
|
||||
{template.items.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{canApply &&
|
||||
(template.scope !== "board" ||
|
||||
template.board_id === currentBoardId) ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void apply(template)}
|
||||
className="app-action-primary rounded-lg px-3 py-2 text-xs disabled:opacity-50"
|
||||
>
|
||||
Добавить в чек-лист
|
||||
</button>
|
||||
) : null}
|
||||
{tab === "personal" && boardId ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void propose(template)}
|
||||
className="app-action-secondary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-xs disabled:opacity-50"
|
||||
>
|
||||
<Send size={13} /> Предложить на доску
|
||||
</button>
|
||||
) : null}
|
||||
{template.can_edit ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => edit(template)}
|
||||
className="app-action-secondary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-xs"
|
||||
>
|
||||
<Pencil size={13} /> Редактировать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void archive(template)}
|
||||
className="app-action-danger inline-flex items-center gap-1 rounded-lg px-3 py-2 text-xs"
|
||||
>
|
||||
<Trash2 size={13} /> Архивировать
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleVersions(template)}
|
||||
className="app-action-secondary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-xs"
|
||||
>
|
||||
<ChevronDown size={13} /> История
|
||||
</button>
|
||||
</div>
|
||||
{openVersions === template.id ? (
|
||||
<div className="app-surface-muted mt-3 space-y-1 rounded-lg p-2 text-xs">
|
||||
{(versions[template.id] || []).map((version) => (
|
||||
<div key={version.version}>
|
||||
v{version.version} ·{" "}
|
||||
{new Date(version.created_at).toLocaleString("ru-RU")} ·{" "}
|
||||
{userLabel(version.changed_by_id)}
|
||||
</div>
|
||||
))}
|
||||
{!versions[template.id] ? "Загрузка истории..." : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
{tab === "board" && boardId ? (
|
||||
<section className="space-y-3 rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<h3 className="text-sm font-semibold">Предложения для доски</h3>
|
||||
{proposals.length === 0 ? (
|
||||
<p className="app-text-muted text-xs">
|
||||
Нет предложений. Сотрудник может предложить свой шаблон во
|
||||
вкладке «Мои».
|
||||
</p>
|
||||
) : (
|
||||
proposals.map((proposal) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="rounded-lg border border-[var(--border-subtle)] p-3"
|
||||
key={proposal.id}
|
||||
className="app-surface-muted rounded-lg p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BookTemplate
|
||||
className="app-text-muted shrink-0"
|
||||
size={16}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
{template.name}
|
||||
</span>
|
||||
<span className="app-text-muted shrink-0 text-xs">
|
||||
{template.is_shared ? "Общий" : "Личный"}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{proposal.name}</span>
|
||||
<span className="app-text-muted text-xs">
|
||||
{proposal.status === "pending"
|
||||
? "На рассмотрении"
|
||||
: proposal.status === "approved"
|
||||
? "Утверждено"
|
||||
: "Отклонено"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
{template.items.length} пунктов
|
||||
{template.description ? ` · ${template.description}` : ""}
|
||||
{proposal.items.length} пунктов · Автор:{" "}
|
||||
{userLabel(proposal.proposed_by_id)}
|
||||
</p>
|
||||
<details className="mt-2">
|
||||
<summary className="app-text-muted cursor-pointer text-xs">
|
||||
Показать пункты
|
||||
<details className="mt-2 text-xs">
|
||||
<summary className="app-text-muted cursor-pointer">
|
||||
Просмотр содержания
|
||||
</summary>
|
||||
<ol className="app-text-muted mt-2 list-decimal space-y-1 pl-5 text-xs">
|
||||
{template.items.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
<ol className="mt-1 list-decimal pl-5">
|
||||
{proposal.items.map((title, i) => (
|
||||
<li key={i}>{title}</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{canApply ? (
|
||||
{rights?.can_manage && proposal.status === "pending" ? (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="app-action-primary rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-50"
|
||||
onClick={() => void apply(template)}
|
||||
disabled={busy}
|
||||
onClick={() => void review(proposal, "approved")}
|
||||
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-xs"
|
||||
>
|
||||
Добавить в чек-лист
|
||||
<Check size={13} /> Опубликовать
|
||||
</button>
|
||||
) : null}
|
||||
{template.created_by_id === userId ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium"
|
||||
onClick={() => edit(template)}
|
||||
disabled={busy}
|
||||
>
|
||||
<Pencil size={13} /> Изменить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="app-action-danger inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium"
|
||||
onClick={() => void remove(template)}
|
||||
disabled={busy}
|
||||
>
|
||||
<Trash2 size={13} /> Удалить
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void review(proposal, "rejected")}
|
||||
className="app-action-secondary rounded-lg px-3 py-2 text-xs"
|
||||
>
|
||||
Отклонить
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{tab === "board" && rights?.is_owner ? (
|
||||
<section className="space-y-3 rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Users size={16} /> Редакторы шаблонов доски
|
||||
</h3>
|
||||
<p className="app-text-muted text-xs">
|
||||
Назначение редактора даёт право публиковать и менять шаблоны этой
|
||||
доски, но не расширяет доступ к задачам.
|
||||
</p>
|
||||
{rights.editor_ids.map((id) => (
|
||||
<div
|
||||
key={id}
|
||||
className="flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<span>{userLabel(id)}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void setEditor(id, false)}
|
||||
className="app-action-danger rounded-lg px-2 py-1.5"
|
||||
>
|
||||
Отозвать
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="app-input min-w-0 flex-1 rounded-lg px-2 py-2 text-sm"
|
||||
value={newEditorId ?? ""}
|
||||
onChange={(event) =>
|
||||
setNewEditorId(Number(event.target.value) || null)
|
||||
}
|
||||
aria-label="Выберите редактора шаблонов доски"
|
||||
>
|
||||
<option value="">Выберите сотрудника</option>
|
||||
{editorCandidates
|
||||
.filter(
|
||||
(employee) =>
|
||||
employee.is_active &&
|
||||
employee.id !== userId &&
|
||||
!rights.editor_ids.includes(employee.id),
|
||||
)
|
||||
.map((employee) => (
|
||||
<option key={employee.id} value={employee.id}>
|
||||
{userLabel(employee.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !newEditorId}
|
||||
onClick={() => newEditorId && void setEditor(newEditorId, true)}
|
||||
className="app-action-primary rounded-lg px-3 py-2 text-xs"
|
||||
>
|
||||
Назначить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{canApply ? (
|
||||
<p className="app-text-muted text-xs">
|
||||
Применение добавляет невыполненные пункты в конец списка.
|
||||
Существующие пункты не удаляются.
|
||||
</p>
|
||||
) : (
|
||||
<p className="app-text-muted text-xs">
|
||||
Чтобы применить шаблон, откройте задачу и нажмите «Шаблоны» в её
|
||||
чек-листе.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{canApply ? (
|
||||
<p className="app-text-muted text-xs">
|
||||
Применение создаёт новые невыполненные пункты. Существующие пункты и
|
||||
сохранённый шаблон не изменяются.
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-red-500">
|
||||
{error}
|
||||
|
||||
@ -14,6 +14,9 @@ import type {
|
||||
TaskAutomationRun,
|
||||
TaskChecklistItem,
|
||||
TaskChecklistTemplate,
|
||||
TaskChecklistTemplateProposal,
|
||||
TaskChecklistTemplateBoardRights,
|
||||
TaskChecklistTemplateRevision,
|
||||
TaskComment,
|
||||
TaskCoverType,
|
||||
TaskLabel,
|
||||
@ -74,6 +77,8 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
|
||||
name: string;
|
||||
description?: string;
|
||||
items: string[];
|
||||
scope?: "personal" | "board" | "company";
|
||||
board_id?: number | null;
|
||||
is_shared?: boolean;
|
||||
}): Promise<TaskChecklistTemplate> =>
|
||||
request("/api/v1/tasks/checklist-templates/", {
|
||||
@ -82,12 +87,7 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
|
||||
}),
|
||||
updateTaskChecklistTemplate: (
|
||||
id: number,
|
||||
data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
items?: string[];
|
||||
is_shared?: boolean;
|
||||
},
|
||||
data: { name?: string; description?: string; items?: string[] },
|
||||
): Promise<TaskChecklistTemplate> =>
|
||||
request(`/api/v1/tasks/checklist-templates/${id}/`, {
|
||||
method: "PATCH",
|
||||
@ -105,6 +105,48 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ task_id: taskId }),
|
||||
}),
|
||||
proposeTaskChecklistTemplate: (
|
||||
id: number,
|
||||
boardId: number,
|
||||
): Promise<TaskChecklistTemplateProposal> =>
|
||||
request(`/api/v1/tasks/checklist-templates/${id}/propose/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ board_id: boardId }),
|
||||
}),
|
||||
getTaskChecklistTemplateProposals: (
|
||||
boardId: number,
|
||||
): Promise<
|
||||
| TaskChecklistTemplateProposal[]
|
||||
| { results: TaskChecklistTemplateProposal[] }
|
||||
> =>
|
||||
request(
|
||||
`/api/v1/tasks/checklist-template-proposals/?board_id=${boardId}`,
|
||||
),
|
||||
reviewTaskChecklistTemplateProposal: (
|
||||
id: number,
|
||||
decision: "approved" | "rejected",
|
||||
): Promise<TaskChecklistTemplateProposal> =>
|
||||
request(`/api/v1/tasks/checklist-template-proposals/${id}/review/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ decision }),
|
||||
}),
|
||||
getTaskChecklistTemplateBoardRights: (
|
||||
boardId: number,
|
||||
): Promise<TaskChecklistTemplateBoardRights> =>
|
||||
request(`/api/v1/tasks/boards/${boardId}/checklist-template-editors/`),
|
||||
setTaskChecklistTemplateBoardEditor: (
|
||||
boardId: number,
|
||||
userId: number,
|
||||
enabled: boolean,
|
||||
): Promise<TaskChecklistTemplateBoardRights> =>
|
||||
request(`/api/v1/tasks/boards/${boardId}/checklist-template-editors/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ user_id: userId, enabled }),
|
||||
}),
|
||||
getTaskChecklistTemplateVersions: (
|
||||
id: number,
|
||||
): Promise<TaskChecklistTemplateRevision[]> =>
|
||||
request(`/api/v1/tasks/checklist-templates/${id}/versions/`),
|
||||
getTaskBoards: (params?: Record<string, string | number>) =>
|
||||
request(`/api/v1/tasks/boards/${buildQuery(params)}`),
|
||||
getTaskBoard: (id: number): Promise<TaskBoard> =>
|
||||
|
||||
@ -464,17 +464,54 @@ export interface TaskBoardViewState {
|
||||
collapsed_row_ids: number[];
|
||||
}
|
||||
|
||||
export type TaskChecklistTemplateScope = "personal" | "board" | "company";
|
||||
|
||||
export interface TaskChecklistTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
created_by_id: number;
|
||||
is_shared: boolean;
|
||||
scope: TaskChecklistTemplateScope;
|
||||
board_id: number | null;
|
||||
version: number;
|
||||
can_edit: boolean;
|
||||
items: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TaskChecklistTemplateProposal {
|
||||
id: number;
|
||||
board_id: number;
|
||||
source_template_id: number | null;
|
||||
proposed_by_id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
status: "pending" | "approved" | "rejected";
|
||||
reviewed_by_id: number | null;
|
||||
published_template_id: number | null;
|
||||
created_at: string;
|
||||
reviewed_at: string | null;
|
||||
}
|
||||
|
||||
export interface TaskChecklistTemplateBoardRights {
|
||||
board_id: number;
|
||||
editor_ids: number[];
|
||||
can_manage: boolean;
|
||||
is_owner: boolean;
|
||||
}
|
||||
|
||||
export interface TaskChecklistTemplateRevision {
|
||||
version: number;
|
||||
changed_by_id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TaskBoardTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user