Add board-scoped checklist templates and publication approvals

This commit is contained in:
lizerk 2026-11-10 17:31:38 +03:00
parent 3b00174ad4
commit 043f99a8fb
15 changed files with 1901 additions and 240 deletions

View File

@ -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",
]

View File

@ -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")

View File

@ -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",

View File

@ -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

View File

@ -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)

View 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)

View File

@ -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",
)
],
},
),
]

View File

@ -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
)
]

View File

@ -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"
)
]

View 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

View File

@ -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.

View File

@ -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={

View File

@ -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}

View File

@ -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> =>

View File

@ -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;