feat(tasks): add automations and comment resources
This commit is contained in:
parent
45866bb69c
commit
6b0643eb75
@ -37,10 +37,17 @@ from tasks.access import (
|
||||
user_can_access_procurement_request,
|
||||
user_can_access_task_board,
|
||||
)
|
||||
from tasks.automation.schema import (
|
||||
AutomationSchemaError,
|
||||
validate_automation_payload,
|
||||
)
|
||||
from tasks.models import (
|
||||
Task,
|
||||
TaskActivity,
|
||||
TaskAttachment,
|
||||
TaskAutomation,
|
||||
TaskAutomationKind,
|
||||
TaskAutomationRun,
|
||||
TaskBoard,
|
||||
TaskBoardAccessScope,
|
||||
TaskBoardGroup,
|
||||
@ -108,6 +115,122 @@ class TaskLabelSerializer(serializers.ModelSerializer):
|
||||
return name
|
||||
|
||||
|
||||
class TaskAutomationRunSerializer(serializers.ModelSerializer):
|
||||
automation_name = serializers.CharField(source="automation.name", read_only=True)
|
||||
task_title = serializers.CharField(source="task.title", read_only=True)
|
||||
actor = EmployeeBriefSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TaskAutomationRun
|
||||
fields = [
|
||||
"id",
|
||||
"automation",
|
||||
"automation_name",
|
||||
"task",
|
||||
"task_title",
|
||||
"trigger_activity",
|
||||
"actor",
|
||||
"source",
|
||||
"status",
|
||||
"context",
|
||||
"actions_log",
|
||||
"error",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class TaskAutomationSerializer(serializers.ModelSerializer):
|
||||
created_by = EmployeeBriefSerializer(read_only=True)
|
||||
boards = serializers.PrimaryKeyRelatedField(
|
||||
queryset=TaskBoard.objects.filter(is_archived=False),
|
||||
many=True,
|
||||
required=False,
|
||||
)
|
||||
runs_count = serializers.IntegerField(read_only=True, default=0)
|
||||
can_manage = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TaskAutomation
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"kind",
|
||||
"trigger",
|
||||
"conditions",
|
||||
"actions",
|
||||
"schedule_config",
|
||||
"applies_to_all_boards",
|
||||
"boards",
|
||||
"is_active",
|
||||
"stop_on_error",
|
||||
"position",
|
||||
"created_by",
|
||||
"last_run_at",
|
||||
"runs_count",
|
||||
"can_manage",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_by",
|
||||
"last_run_at",
|
||||
"runs_count",
|
||||
"can_manage",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def get_can_manage(self, obj):
|
||||
user = getattr(self.context.get("request"), "user", None)
|
||||
return bool(user and (user.is_staff or user.is_superuser))
|
||||
|
||||
def validate_name(self, value):
|
||||
name = value.strip()
|
||||
if not name:
|
||||
raise serializers.ValidationError("Введите название автоматизации.")
|
||||
return name
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super().validate(attrs)
|
||||
instance = self.instance
|
||||
kind = attrs.get("kind", getattr(instance, "kind", TaskAutomationKind.EVENT))
|
||||
trigger = attrs.get("trigger", getattr(instance, "trigger", "task_updated"))
|
||||
conditions = attrs.get("conditions", getattr(instance, "conditions", {}))
|
||||
actions = attrs.get("actions", getattr(instance, "actions", []))
|
||||
schedule_config = attrs.get(
|
||||
"schedule_config",
|
||||
getattr(instance, "schedule_config", {}),
|
||||
)
|
||||
try:
|
||||
normalized = validate_automation_payload(
|
||||
kind=kind,
|
||||
trigger=trigger,
|
||||
conditions=conditions,
|
||||
actions=actions,
|
||||
schedule_config=schedule_config,
|
||||
)
|
||||
except AutomationSchemaError as error:
|
||||
raise serializers.ValidationError({"detail": str(error)}) from error
|
||||
attrs.update(normalized)
|
||||
|
||||
applies_to_all = attrs.get(
|
||||
"applies_to_all_boards",
|
||||
getattr(instance, "applies_to_all_boards", True),
|
||||
)
|
||||
boards = attrs.get("boards")
|
||||
if not applies_to_all and boards is not None and not boards:
|
||||
raise serializers.ValidationError(
|
||||
{"boards": "Выберите хотя бы одну доску или включите все доски."}
|
||||
)
|
||||
if applies_to_all:
|
||||
attrs["boards"] = []
|
||||
return attrs
|
||||
|
||||
|
||||
class TaskColumnSerializer(serializers.ModelSerializer):
|
||||
tasks_count = serializers.IntegerField(read_only=True, default=0)
|
||||
subcolumns_count = serializers.SerializerMethodField()
|
||||
@ -1030,6 +1153,7 @@ class TaskAttachmentSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
"id",
|
||||
"task",
|
||||
"comment",
|
||||
"file_name",
|
||||
"file_size",
|
||||
"mime_type",
|
||||
|
||||
@ -2,6 +2,7 @@ from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
TaskAutomationViewSet,
|
||||
TaskBoardGroupViewSet,
|
||||
TaskBoardViewSet,
|
||||
TaskColumnViewSet,
|
||||
@ -13,6 +14,7 @@ from .views import (
|
||||
app_name = "tasks"
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"automations", TaskAutomationViewSet, basename="task-automation")
|
||||
router.register(r"board-groups", TaskBoardGroupViewSet, basename="task-board-group")
|
||||
router.register(r"boards", TaskBoardViewSet, basename="task-board")
|
||||
router.register(r"columns", TaskColumnViewSet, basename="task-column")
|
||||
|
||||
@ -32,7 +32,7 @@ from procurement.models import ProcurementRequest
|
||||
from requests_app.models import Request as EmployeeRequest
|
||||
from rest_framework import filters, parsers, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.permissions import IsAdminUser, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from schedule.models import Event
|
||||
|
||||
@ -48,12 +48,19 @@ from tasks.access import (
|
||||
user_can_access_attendance_record,
|
||||
user_can_access_procurement_request,
|
||||
)
|
||||
from tasks.automation.engine import conditions_match, execute_automation
|
||||
from tasks.automation.schema import automation_catalog
|
||||
from tasks.comment_resources import add_comment_links_to_task
|
||||
from tasks.models import (
|
||||
Task,
|
||||
TaskActivity,
|
||||
TaskActivityAction,
|
||||
TaskActivityObjectKind,
|
||||
TaskAttachment,
|
||||
TaskAutomation,
|
||||
TaskAutomationKind,
|
||||
TaskAutomationRun,
|
||||
TaskAutomationRunStatus,
|
||||
TaskBoard,
|
||||
TaskBoardGroup,
|
||||
TaskBoardPlacement,
|
||||
@ -84,6 +91,8 @@ from .serializers import (
|
||||
EmployeeBriefSerializer,
|
||||
TaskActivitySerializer,
|
||||
TaskAttachmentSerializer,
|
||||
TaskAutomationRunSerializer,
|
||||
TaskAutomationSerializer,
|
||||
TaskBoardSerializer,
|
||||
TaskBoardGroupSerializer,
|
||||
TaskBoardSummarySerializer,
|
||||
@ -174,6 +183,111 @@ def send_task_checklist_update(task, event, item_id):
|
||||
)
|
||||
|
||||
|
||||
class TaskAutomationViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = TaskAutomationSerializer
|
||||
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||||
search_fields = ["name", "description"]
|
||||
ordering_fields = ["position", "name", "created_at", "last_run_at"]
|
||||
ordering = ["kind", "position", "name", "id"]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in {"catalog", "buttons", "run"}:
|
||||
return [IsAuthenticated()]
|
||||
return [IsAdminUser()]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = (
|
||||
TaskAutomation.objects.select_related("created_by")
|
||||
.prefetch_related("boards")
|
||||
.annotate(runs_count=Count("runs", distinct=True))
|
||||
)
|
||||
kind = self.request.query_params.get("kind")
|
||||
if kind:
|
||||
queryset = queryset.filter(kind=kind)
|
||||
if self.request.query_params.get("active") in {"true", "false"}:
|
||||
queryset = queryset.filter(
|
||||
is_active=self.request.query_params["active"] == "true"
|
||||
)
|
||||
if board_id := self.request.query_params.get("board"):
|
||||
queryset = queryset.filter(
|
||||
Q(applies_to_all_boards=True) | Q(boards=board_id)
|
||||
).distinct()
|
||||
return queryset
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(created_by=self.request.user)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def catalog(self, request):
|
||||
return Response(automation_catalog())
|
||||
|
||||
def _accessible_task(self, request, task_id):
|
||||
return get_object_or_404(
|
||||
Task.objects.select_related("board", "column", "created_by", "assignee")
|
||||
.filter(
|
||||
board__in=TaskBoard.objects.filter(task_board_access_q(request.user))
|
||||
),
|
||||
id=task_id,
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def buttons(self, request):
|
||||
task_id = request.query_params.get("task")
|
||||
if not task_id:
|
||||
return Response(
|
||||
{"task": ["Укажите задачу."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
task = self._accessible_task(request, task_id)
|
||||
queryset = self.get_queryset().filter(
|
||||
kind=TaskAutomationKind.BUTTON,
|
||||
is_active=True,
|
||||
).filter(Q(applies_to_all_boards=True) | Q(boards=task.board_id)).distinct()
|
||||
eligible = [automation for automation in queryset if conditions_match(task, automation.conditions)]
|
||||
return Response(self.get_serializer(eligible, many=True).data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def run(self, request, pk=None):
|
||||
automation = self.get_object()
|
||||
if automation.kind != TaskAutomationKind.BUTTON or not automation.is_active:
|
||||
return Response(
|
||||
{"detail": "Эту автоматизацию нельзя запустить вручную."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
task_id = request.data.get("task")
|
||||
if not task_id:
|
||||
return Response(
|
||||
{"task": ["Укажите задачу."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
task = self._accessible_task(request, task_id)
|
||||
run = execute_automation(
|
||||
automation,
|
||||
task,
|
||||
source="manual",
|
||||
actor=request.user,
|
||||
)
|
||||
if not run:
|
||||
return Response(
|
||||
{"detail": "Автоматизация не была запущена."},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
if run.status == TaskAutomationRunStatus.SKIPPED:
|
||||
return Response(
|
||||
{"detail": run.error or "Условия автоматизации не выполнены."},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
return Response(TaskAutomationRunSerializer(run).data)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def runs(self, request, pk=None):
|
||||
automation = self.get_object()
|
||||
queryset = TaskAutomationRun.objects.filter(
|
||||
automation=automation,
|
||||
).select_related("automation", "task", "actor")[:100]
|
||||
return Response(TaskAutomationRunSerializer(queryset, many=True).data)
|
||||
|
||||
|
||||
class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = TaskBoardSerializer
|
||||
@ -1089,7 +1203,11 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
task,
|
||||
self.request.user,
|
||||
TaskActivityAction.UPDATED,
|
||||
metadata={"fields": changed_fields},
|
||||
metadata={
|
||||
"fields": changed_fields,
|
||||
"previous": previous,
|
||||
"current": current,
|
||||
},
|
||||
)
|
||||
notify_task_updated(task, self.request.user, previous, current)
|
||||
send_task_board_update(task.board, "updated", "task", task.id)
|
||||
@ -1721,6 +1839,11 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def _comment_payload(self, task, message):
|
||||
attachments = [
|
||||
attachment
|
||||
for attachment in message.task_attachments.all()
|
||||
if attachment.task_id == task.id
|
||||
]
|
||||
return {
|
||||
"id": message.id,
|
||||
"task": task.id,
|
||||
@ -1729,45 +1852,121 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
"is_edited": message.is_edited,
|
||||
"edited_at": message.edited_at,
|
||||
"created_at": message.created_at,
|
||||
"attachments": TaskAttachmentSerializer(
|
||||
attachments,
|
||||
many=True,
|
||||
context=self.get_serializer_context(),
|
||||
).data,
|
||||
}
|
||||
|
||||
@action(detail=True, methods=["get", "post"], url_path="comments")
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["get", "post"],
|
||||
url_path="comments",
|
||||
parser_classes=[
|
||||
parsers.JSONParser,
|
||||
parsers.MultiPartParser,
|
||||
parsers.FormParser,
|
||||
],
|
||||
)
|
||||
def comments(self, request, pk=None):
|
||||
task = self.get_object()
|
||||
|
||||
if request.method in ("GET", "HEAD"):
|
||||
messages = comments_helpers.get_comments(task)
|
||||
messages = comments_helpers.get_comments(task).prefetch_related(
|
||||
Prefetch(
|
||||
"task_attachments",
|
||||
queryset=TaskAttachment.objects.filter(task=task).select_related(
|
||||
"uploaded_by"
|
||||
),
|
||||
)
|
||||
)
|
||||
return Response(
|
||||
[self._comment_payload(task, message) for message in messages]
|
||||
)
|
||||
|
||||
text = request.data.get("text", "").strip()
|
||||
if not text:
|
||||
files = request.FILES.getlist("files")
|
||||
if not files and request.FILES.get("file"):
|
||||
files = [request.FILES["file"]]
|
||||
if not text and not files:
|
||||
return Response(
|
||||
{"text": ["Это поле не может быть пустым."]},
|
||||
{"detail": "Введите комментарий или добавьте файл."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
message = comments_helpers.create_comment(
|
||||
obj=task,
|
||||
author=request.user,
|
||||
content=text,
|
||||
)
|
||||
create_task_activity(
|
||||
task,
|
||||
request.user,
|
||||
TaskActivityAction.COMMENT_ADDED,
|
||||
object_kind=TaskActivityObjectKind.COMMENT,
|
||||
object_id=message.id,
|
||||
metadata={"comment_text": message.content},
|
||||
)
|
||||
with transaction.atomic():
|
||||
message = comments_helpers.create_comment(
|
||||
obj=task,
|
||||
author=request.user,
|
||||
content=text,
|
||||
)
|
||||
created_attachments = []
|
||||
for uploaded in files:
|
||||
file_name = Path(uploaded.name.replace("\\", "/")).name[:255]
|
||||
attachment = TaskAttachment.objects.create(
|
||||
task=task,
|
||||
comment=message,
|
||||
file=uploaded,
|
||||
file_name=file_name,
|
||||
file_size=uploaded.size,
|
||||
mime_type=(uploaded.content_type or "")[:255],
|
||||
uploaded_by=request.user,
|
||||
)
|
||||
created_attachments.append(attachment)
|
||||
create_task_activity(
|
||||
task,
|
||||
request.user,
|
||||
TaskActivityAction.ATTACHMENT_ADDED,
|
||||
object_id=attachment.id,
|
||||
metadata={
|
||||
"file_name": attachment.file_name,
|
||||
"comment_id": message.id,
|
||||
},
|
||||
)
|
||||
if created_attachments:
|
||||
message.has_attachments = True
|
||||
message.save(update_fields=["has_attachments"])
|
||||
|
||||
created_links = add_comment_links_to_task(
|
||||
task=task,
|
||||
text=text,
|
||||
created_by=request.user,
|
||||
)
|
||||
for link in created_links:
|
||||
create_task_activity(
|
||||
task,
|
||||
request.user,
|
||||
TaskActivityAction.LINKED,
|
||||
object_kind=TaskActivityObjectKind.EXTERNAL_LINK,
|
||||
object_id=link.id,
|
||||
metadata={
|
||||
"url": link.url,
|
||||
"comment_id": message.id,
|
||||
},
|
||||
)
|
||||
|
||||
create_task_activity(
|
||||
task,
|
||||
request.user,
|
||||
TaskActivityAction.COMMENT_ADDED,
|
||||
object_kind=TaskActivityObjectKind.COMMENT,
|
||||
object_id=message.id,
|
||||
metadata={"comment_text": message.content},
|
||||
)
|
||||
notify_task_comment(task, message)
|
||||
send_task_board_update(
|
||||
task.board,
|
||||
"commented",
|
||||
"task",
|
||||
task.id,
|
||||
extra={"comment_id": message.id},
|
||||
extra={
|
||||
"comment_id": message.id,
|
||||
"attachments_count": task.attachments.count(),
|
||||
"linked_objects_count": (
|
||||
task.linked_objects.count() + task.external_links.count()
|
||||
),
|
||||
},
|
||||
)
|
||||
return Response(
|
||||
self._comment_payload(task, message),
|
||||
@ -1824,6 +2023,23 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
text,
|
||||
edited_by=request.user,
|
||||
)
|
||||
created_links = add_comment_links_to_task(
|
||||
task=task,
|
||||
text=text,
|
||||
created_by=request.user,
|
||||
)
|
||||
for link in created_links:
|
||||
create_task_activity(
|
||||
task,
|
||||
request.user,
|
||||
TaskActivityAction.LINKED,
|
||||
object_kind=TaskActivityObjectKind.EXTERNAL_LINK,
|
||||
object_id=link.id,
|
||||
metadata={
|
||||
"url": link.url,
|
||||
"comment_id": message.id,
|
||||
},
|
||||
)
|
||||
create_task_activity(
|
||||
task,
|
||||
request.user,
|
||||
@ -1855,6 +2071,7 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
comment_text = message.content
|
||||
task.attachments.filter(comment=message).update(comment=None)
|
||||
comments_helpers.delete_comment(
|
||||
message=message,
|
||||
deleted_by=request.user,
|
||||
|
||||
@ -728,6 +728,10 @@ CELERY_BEAT_SCHEDULE = {
|
||||
"task": "tasks.dispatch_task_due_notifications",
|
||||
"schedule": crontab(hour=9, minute=5),
|
||||
},
|
||||
"tasks-scheduled-automations": {
|
||||
"task": "tasks.dispatch_scheduled_automations",
|
||||
"schedule": 60.0,
|
||||
},
|
||||
"process-ldap-sync-queue": {
|
||||
"task": "employees.tasks.process_ldap_queue",
|
||||
"schedule": 60.0, # Каждую минуту проверяем очередь LDAP retry
|
||||
|
||||
@ -4,6 +4,8 @@ from .models import (
|
||||
Task,
|
||||
TaskActivity,
|
||||
TaskAttachment,
|
||||
TaskAutomation,
|
||||
TaskAutomationRun,
|
||||
TaskBoard,
|
||||
TaskBoardGroup,
|
||||
TaskBoardPlacement,
|
||||
@ -88,6 +90,45 @@ class TaskLabelAdmin(admin.ModelAdmin):
|
||||
search_fields = ["name"]
|
||||
|
||||
|
||||
@admin.register(TaskAutomation)
|
||||
class TaskAutomationAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"name",
|
||||
"kind",
|
||||
"trigger",
|
||||
"applies_to_all_boards",
|
||||
"is_active",
|
||||
"last_run_at",
|
||||
]
|
||||
list_filter = ["kind", "trigger", "applies_to_all_boards", "is_active"]
|
||||
search_fields = ["name", "description"]
|
||||
filter_horizontal = ["boards"]
|
||||
autocomplete_fields = ["created_by"]
|
||||
|
||||
|
||||
@admin.register(TaskAutomationRun)
|
||||
class TaskAutomationRunAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"automation",
|
||||
"task",
|
||||
"source",
|
||||
"status",
|
||||
"actor",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
]
|
||||
list_filter = ["source", "status", "started_at"]
|
||||
search_fields = ["automation__name", "task__title", "error"]
|
||||
autocomplete_fields = ["automation", "task", "actor", "trigger_activity"]
|
||||
readonly_fields = [
|
||||
"idempotency_key",
|
||||
"context",
|
||||
"actions_log",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
]
|
||||
|
||||
|
||||
@admin.register(Task)
|
||||
class TaskAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
|
||||
11
backend/tasks/automation/__init__.py
Normal file
11
backend/tasks/automation/__init__.py
Normal file
@ -0,0 +1,11 @@
|
||||
from .engine import (
|
||||
dispatch_scheduled_automations,
|
||||
execute_automation,
|
||||
process_activity_automations,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"dispatch_scheduled_automations",
|
||||
"execute_automation",
|
||||
"process_activity_automations",
|
||||
]
|
||||
593
backend/tasks/automation/engine.py
Normal file
593
backend/tasks/automation/engine.py
Normal file
@ -0,0 +1,593 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.db.models import Max, Q
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_date, parse_datetime, parse_time
|
||||
|
||||
from communications import comments_helpers
|
||||
from tasks.access import user_can_access_task_board
|
||||
from tasks.models import (
|
||||
Task,
|
||||
TaskActivity,
|
||||
TaskActivityAction,
|
||||
TaskActivityObjectKind,
|
||||
TaskAutomation,
|
||||
TaskAutomationKind,
|
||||
TaskAutomationRun,
|
||||
TaskAutomationRunStatus,
|
||||
TaskAutomationTrigger,
|
||||
TaskChecklistItem,
|
||||
TaskColumn,
|
||||
)
|
||||
from tasks.notifications import (
|
||||
notify_task_activity_change,
|
||||
notify_task_comment,
|
||||
notify_task_moved,
|
||||
notify_task_updated,
|
||||
)
|
||||
from tasks.realtime import send_task_board_update
|
||||
|
||||
User = get_user_model()
|
||||
MAX_AUTOMATION_CHAIN_DEPTH = 10
|
||||
|
||||
|
||||
def _task_snapshot(task):
|
||||
return {
|
||||
"title": task.title,
|
||||
"description": task.description,
|
||||
"assignee_id": task.assignee_id,
|
||||
"priority": task.priority,
|
||||
"due_date": task.due_date.isoformat() if task.due_date else None,
|
||||
"row_id": task.row_id,
|
||||
"label_ids": sorted(task.labels.values_list("id", flat=True)),
|
||||
"participant_ids": sorted(task.participants.values_list("id", flat=True)),
|
||||
}
|
||||
|
||||
|
||||
def _record_activity(
|
||||
task,
|
||||
actor,
|
||||
action,
|
||||
*,
|
||||
chain,
|
||||
object_kind="",
|
||||
object_id=None,
|
||||
metadata=None,
|
||||
):
|
||||
activity_metadata = dict(metadata or {})
|
||||
activity_metadata["_automation_chain"] = chain
|
||||
activity = TaskActivity.objects.create(
|
||||
task=task,
|
||||
actor=actor if getattr(actor, "is_authenticated", False) else None,
|
||||
action=action,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
metadata=activity_metadata,
|
||||
)
|
||||
notify_task_activity_change(
|
||||
task,
|
||||
actor,
|
||||
action,
|
||||
object_kind=object_kind,
|
||||
metadata=activity_metadata,
|
||||
)
|
||||
return activity
|
||||
|
||||
|
||||
def _field_value(task, field):
|
||||
if field == "board_id":
|
||||
return task.board_id
|
||||
if field == "column_id":
|
||||
return task.column_id
|
||||
if field == "column_name":
|
||||
return task.column.name
|
||||
if field == "assignee_id":
|
||||
return task.assignee_id
|
||||
if field == "creator_id":
|
||||
return task.created_by_id
|
||||
if field == "participant_ids":
|
||||
return list(task.participants.values_list("id", flat=True))
|
||||
if field == "label_ids":
|
||||
return list(task.labels.values_list("id", flat=True))
|
||||
if field == "priority":
|
||||
return task.priority
|
||||
if field == "due_date":
|
||||
return task.due_date
|
||||
if field == "completed":
|
||||
return bool(task.completed_at or task.column.is_done)
|
||||
if field == "title":
|
||||
return task.title
|
||||
if field == "description":
|
||||
return task.description
|
||||
if field == "has_attachments":
|
||||
return task.attachments.exists()
|
||||
if field == "checklist_complete":
|
||||
total = task.checklist_items.count()
|
||||
return bool(total and not task.checklist_items.filter(is_completed=False).exists())
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_comparable(value):
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
def _compare(actual, operator, expected):
|
||||
if operator == "is_empty":
|
||||
return actual in (None, "", [], (), {})
|
||||
if operator == "not_empty":
|
||||
return actual not in (None, "", [], (), {})
|
||||
if operator in {"is_today", "is_past", "is_future"}:
|
||||
actual_date = actual if isinstance(actual, date) else parse_date(str(actual or ""))
|
||||
if not actual_date:
|
||||
return False
|
||||
today = timezone.localdate()
|
||||
return {
|
||||
"is_today": actual_date == today,
|
||||
"is_past": actual_date < today,
|
||||
"is_future": actual_date > today,
|
||||
}[operator]
|
||||
|
||||
actual = _normalize_comparable(actual)
|
||||
expected = _normalize_comparable(expected)
|
||||
if operator == "eq":
|
||||
return actual == expected
|
||||
if operator == "neq":
|
||||
return actual != expected
|
||||
if operator == "contains":
|
||||
if isinstance(actual, (list, tuple, set)):
|
||||
return expected in actual
|
||||
return str(expected).casefold() in str(actual or "").casefold()
|
||||
if operator == "not_contains":
|
||||
return not _compare(actual, "contains", expected)
|
||||
if operator == "in":
|
||||
return actual in (expected if isinstance(expected, list) else [expected])
|
||||
if operator == "not_in":
|
||||
return not _compare(actual, "in", expected)
|
||||
if operator in {"gt", "gte", "lt", "lte"}:
|
||||
if actual is None or expected is None:
|
||||
return False
|
||||
try:
|
||||
return {
|
||||
"gt": actual > expected,
|
||||
"gte": actual >= expected,
|
||||
"lt": actual < expected,
|
||||
"lte": actual <= expected,
|
||||
}[operator]
|
||||
except TypeError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def conditions_match(task, node):
|
||||
if not node:
|
||||
return True
|
||||
operator = node.get("operator", "and")
|
||||
if "children" in node:
|
||||
children = node.get("children") or []
|
||||
if operator == "and":
|
||||
return all(conditions_match(task, child) for child in children)
|
||||
if operator == "or":
|
||||
return any(conditions_match(task, child) for child in children)
|
||||
if operator == "not":
|
||||
return bool(children) and not conditions_match(task, children[0])
|
||||
return False
|
||||
return _compare(_field_value(task, node.get("field")), operator, node.get("value"))
|
||||
|
||||
|
||||
def _resolve_target_column(task, action):
|
||||
target = action.get("target", "column")
|
||||
columns = TaskColumn.objects.filter(board_id=task.board_id, is_archived=False)
|
||||
if target == "first":
|
||||
column = columns.filter(parent__isnull=True).order_by("position", "id").first()
|
||||
elif target == "final":
|
||||
column = columns.filter(parent__isnull=True, is_done=True).first()
|
||||
elif target == "column_name":
|
||||
column = columns.filter(name__iexact=str(action.get("column_name", "")).strip()).first()
|
||||
else:
|
||||
column = columns.filter(id=action.get("column_id")).first()
|
||||
if not column:
|
||||
raise ValueError("Целевая колонка не найдена на доске задачи.")
|
||||
if column.parent_id is None:
|
||||
first_subcolumn = column.subcolumns.filter(is_archived=False).order_by("position", "id").first()
|
||||
if first_subcolumn:
|
||||
column = first_subcolumn
|
||||
return column
|
||||
|
||||
|
||||
def _move_task(task, action, actor, chain):
|
||||
old_column = task.column
|
||||
old_row = task.row
|
||||
column = _resolve_target_column(task, action)
|
||||
top_column_id = column.parent_id or column.id
|
||||
row = old_row if old_row and old_row.column_id == top_column_id else None
|
||||
if old_column.id == column.id and old_row == row:
|
||||
return "Задача уже находится в целевой колонке"
|
||||
position = (
|
||||
Task.objects.filter(column=column, row=row).exclude(id=task.id).aggregate(
|
||||
value=Max("position")
|
||||
)["value"]
|
||||
or 0
|
||||
) + 1000
|
||||
task.column = column
|
||||
task.row = row
|
||||
task.position = position
|
||||
task.save(update_fields=["column", "row", "position", "completed_at", "updated_at"])
|
||||
task.refresh_from_db()
|
||||
metadata = {
|
||||
"from_column_id": old_column.id,
|
||||
"from_column": old_column.name,
|
||||
"to_column_id": column.id,
|
||||
"to_column": column.name,
|
||||
"from_row_id": old_row.id if old_row else None,
|
||||
"to_row_id": row.id if row else None,
|
||||
}
|
||||
_record_activity(task, actor, TaskActivityAction.MOVED, chain=chain, metadata=metadata)
|
||||
notify_task_moved(task, actor, old_column, column)
|
||||
send_task_board_update(
|
||||
task.board,
|
||||
"moved",
|
||||
"task",
|
||||
task.id,
|
||||
extra={"column_id": task.column_id, "row_id": task.row_id},
|
||||
)
|
||||
return f"Перемещено в «{column.name}»"
|
||||
|
||||
|
||||
def _apply_field_action(task, action, actor, chain):
|
||||
previous = _task_snapshot(task)
|
||||
action_type = action["type"]
|
||||
update_fields = []
|
||||
|
||||
if action_type == "set_assignee":
|
||||
user_id = action.get("user_id")
|
||||
assignee = None
|
||||
if user_id:
|
||||
assignee = User.objects.filter(id=user_id, is_active=True).first()
|
||||
if not assignee or not user_can_access_task_board(assignee, task.board):
|
||||
raise ValueError("Исполнитель не имеет доступа к доске.")
|
||||
task.assignee = assignee
|
||||
update_fields.append("assignee")
|
||||
elif action_type == "set_priority":
|
||||
task.priority = action["priority"]
|
||||
update_fields.append("priority")
|
||||
elif action_type == "set_due_date":
|
||||
mode = action.get("mode", "clear")
|
||||
if mode == "fixed":
|
||||
due_date = parse_date(str(action.get("date", "")))
|
||||
if not due_date:
|
||||
raise ValueError("Некорректная дата срока.")
|
||||
task.due_date = due_date
|
||||
elif mode == "relative":
|
||||
task.due_date = timezone.localdate() + timedelta(days=int(action.get("days", 0)))
|
||||
else:
|
||||
task.due_date = None
|
||||
update_fields.append("due_date")
|
||||
elif action_type == "add_participant":
|
||||
participant = User.objects.filter(id=action.get("user_id"), is_active=True).first()
|
||||
if not participant or not user_can_access_task_board(participant, task.board):
|
||||
raise ValueError("Участник не имеет доступа к доске.")
|
||||
task.participants.add(participant)
|
||||
elif action_type == "remove_participant":
|
||||
user_id = action.get("user_id")
|
||||
if user_id not in {task.created_by_id, task.assignee_id}:
|
||||
task.participants.remove(user_id)
|
||||
elif action_type == "add_label":
|
||||
task.labels.add(action["label_id"])
|
||||
elif action_type == "remove_label":
|
||||
task.labels.remove(action["label_id"])
|
||||
|
||||
if update_fields:
|
||||
task.save(update_fields=[*update_fields, "updated_at"])
|
||||
task.refresh_from_db()
|
||||
current = _task_snapshot(task)
|
||||
changed_fields = [key for key in current if current[key] != previous[key]]
|
||||
if changed_fields:
|
||||
_record_activity(
|
||||
task,
|
||||
actor,
|
||||
TaskActivityAction.UPDATED,
|
||||
chain=chain,
|
||||
metadata={
|
||||
"fields": changed_fields,
|
||||
"previous": previous,
|
||||
"current": current,
|
||||
},
|
||||
)
|
||||
notify_task_updated(task, actor, previous, current)
|
||||
send_task_board_update(task.board, "updated", "task", task.id)
|
||||
return ", ".join(changed_fields) if changed_fields else "Изменений нет"
|
||||
|
||||
|
||||
def _execute_action(task, action, actor, chain, automation):
|
||||
action_type = action["type"]
|
||||
if action_type in {
|
||||
"set_assignee",
|
||||
"add_participant",
|
||||
"remove_participant",
|
||||
"add_label",
|
||||
"remove_label",
|
||||
"set_priority",
|
||||
"set_due_date",
|
||||
}:
|
||||
return _apply_field_action(task, action, actor, chain)
|
||||
if action_type == "move":
|
||||
return _move_task(task, action, actor, chain)
|
||||
if action_type == "complete":
|
||||
return _move_task(task, {"target": "final", "type": "move"}, actor, chain)
|
||||
if action_type == "add_comment":
|
||||
author = actor or automation.created_by
|
||||
message = comments_helpers.create_comment(
|
||||
obj=task,
|
||||
author=author,
|
||||
content=str(action["text"]).strip(),
|
||||
automation_id=automation.id,
|
||||
)
|
||||
_record_activity(
|
||||
task,
|
||||
actor,
|
||||
TaskActivityAction.COMMENT_ADDED,
|
||||
chain=chain,
|
||||
object_kind=TaskActivityObjectKind.COMMENT,
|
||||
object_id=message.id,
|
||||
metadata={"comment_text": message.content},
|
||||
)
|
||||
notify_task_comment(task, message)
|
||||
send_task_board_update(task.board, "commented", "task", task.id)
|
||||
return "Комментарий добавлен"
|
||||
if action_type == "add_checklist":
|
||||
author = actor or automation.created_by
|
||||
existing_max = task.checklist_items.aggregate(value=Max("position"))["value"] or 0
|
||||
created = 0
|
||||
for index, raw_title in enumerate(action.get("items") or [], start=1):
|
||||
title = str(raw_title).strip()
|
||||
if not title:
|
||||
continue
|
||||
item = TaskChecklistItem.objects.create(
|
||||
task=task,
|
||||
title=title,
|
||||
position=existing_max + index * 1000,
|
||||
created_by=author,
|
||||
)
|
||||
_record_activity(
|
||||
task,
|
||||
actor,
|
||||
TaskActivityAction.CHECKLIST_ITEM_ADDED,
|
||||
chain=chain,
|
||||
object_kind=TaskActivityObjectKind.CHECKLIST_ITEM,
|
||||
object_id=item.id,
|
||||
metadata={"item_title": item.title},
|
||||
)
|
||||
created += 1
|
||||
send_task_board_update(task.board, "checklist_updated", "task", task.id)
|
||||
return f"Добавлено пунктов: {created}"
|
||||
raise ValueError(f"Действие {action_type} не поддерживается.")
|
||||
|
||||
|
||||
def execute_automation(
|
||||
automation,
|
||||
task,
|
||||
*,
|
||||
source,
|
||||
actor=None,
|
||||
trigger_activity=None,
|
||||
idempotency_key=None,
|
||||
chain=None,
|
||||
context=None,
|
||||
):
|
||||
chain = [int(value) for value in (chain or []) if str(value).isdigit()]
|
||||
if automation.id in chain or len(chain) >= MAX_AUTOMATION_CHAIN_DEPTH:
|
||||
return None
|
||||
idempotency_key = idempotency_key or f"manual:{automation.id}:{task.id}:{uuid.uuid4()}"
|
||||
run, created = TaskAutomationRun.objects.get_or_create(
|
||||
idempotency_key=idempotency_key,
|
||||
defaults={
|
||||
"automation": automation,
|
||||
"task": task,
|
||||
"trigger_activity": trigger_activity,
|
||||
"actor": actor if getattr(actor, "is_authenticated", False) else None,
|
||||
"source": source,
|
||||
"context": context or {},
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
return run
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
task = (
|
||||
Task.objects.select_for_update()
|
||||
.select_related("board", "column", "row", "created_by", "assignee")
|
||||
.get(id=task.id)
|
||||
)
|
||||
if not automation.is_active or not automation.applies_to_board(task.board_id):
|
||||
run.status = TaskAutomationRunStatus.SKIPPED
|
||||
run.error = "Автоматизация выключена или не применяется к доске."
|
||||
elif not conditions_match(task, automation.conditions):
|
||||
run.status = TaskAutomationRunStatus.SKIPPED
|
||||
run.error = "Условия не выполнены."
|
||||
else:
|
||||
action_chain = [*chain, automation.id]
|
||||
action_log = []
|
||||
failures = 0
|
||||
for index, action in enumerate(automation.actions):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
result = _execute_action(
|
||||
task,
|
||||
action,
|
||||
actor,
|
||||
action_chain,
|
||||
automation,
|
||||
)
|
||||
action_log.append({
|
||||
"index": index,
|
||||
"type": action.get("type"),
|
||||
"status": "success",
|
||||
"result": result,
|
||||
})
|
||||
except Exception as error:
|
||||
task.refresh_from_db()
|
||||
failures += 1
|
||||
action_log.append({
|
||||
"index": index,
|
||||
"type": action.get("type"),
|
||||
"status": "failed",
|
||||
"error": str(error),
|
||||
})
|
||||
if automation.stop_on_error:
|
||||
break
|
||||
run.actions_log = action_log
|
||||
run.status = (
|
||||
TaskAutomationRunStatus.FAILED
|
||||
if failures and failures == len(action_log)
|
||||
else TaskAutomationRunStatus.PARTIAL
|
||||
if failures
|
||||
else TaskAutomationRunStatus.SUCCESS
|
||||
)
|
||||
if failures:
|
||||
run.error = next(
|
||||
item["error"] for item in action_log if item["status"] == "failed"
|
||||
)
|
||||
except Exception as error:
|
||||
run.status = TaskAutomationRunStatus.FAILED
|
||||
run.error = str(error)
|
||||
run.finished_at = timezone.now()
|
||||
run.save(update_fields=["status", "actions_log", "error", "finished_at"])
|
||||
TaskAutomation.objects.filter(id=automation.id).update(last_run_at=run.finished_at)
|
||||
return run
|
||||
|
||||
|
||||
def activity_triggers(activity):
|
||||
triggers = set()
|
||||
if activity.action == TaskActivityAction.CREATED:
|
||||
triggers.add(TaskAutomationTrigger.TASK_CREATED)
|
||||
elif activity.action == TaskActivityAction.UPDATED:
|
||||
triggers.add(TaskAutomationTrigger.TASK_UPDATED)
|
||||
fields = set(activity.metadata.get("fields") or [])
|
||||
trigger_by_field = {
|
||||
"assignee_id": TaskAutomationTrigger.ASSIGNEE_CHANGED,
|
||||
"due_date": TaskAutomationTrigger.DUE_DATE_CHANGED,
|
||||
"priority": TaskAutomationTrigger.PRIORITY_CHANGED,
|
||||
}
|
||||
triggers.update(trigger_by_field[field] for field in fields if field in trigger_by_field)
|
||||
if "label_ids" in fields:
|
||||
previous = set(activity.metadata.get("previous", {}).get("label_ids") or [])
|
||||
current = set(activity.metadata.get("current", {}).get("label_ids") or [])
|
||||
if current - previous:
|
||||
triggers.add(TaskAutomationTrigger.LABEL_ADDED)
|
||||
if previous - current:
|
||||
triggers.add(TaskAutomationTrigger.LABEL_REMOVED)
|
||||
elif activity.action == TaskActivityAction.MOVED:
|
||||
triggers.add(TaskAutomationTrigger.TASK_MOVED)
|
||||
elif activity.action == TaskActivityAction.ATTACHMENT_ADDED:
|
||||
triggers.add(TaskAutomationTrigger.ATTACHMENT_ADDED)
|
||||
elif activity.action == TaskActivityAction.COMMENT_ADDED:
|
||||
triggers.add(TaskAutomationTrigger.COMMENT_ADDED)
|
||||
elif activity.action == TaskActivityAction.CHECKLIST_ITEM_COMPLETED:
|
||||
triggers.add(TaskAutomationTrigger.CHECKLIST_COMPLETED)
|
||||
elif activity.action == TaskActivityAction.LINKED:
|
||||
triggers.add(TaskAutomationTrigger.LINKED_OBJECT_ADDED)
|
||||
return triggers
|
||||
|
||||
|
||||
def process_activity_automations(activity_id):
|
||||
activity = (
|
||||
TaskActivity.objects.select_related("task__board", "task__column", "actor")
|
||||
.filter(id=activity_id)
|
||||
.first()
|
||||
)
|
||||
if not activity:
|
||||
return 0
|
||||
triggers = activity_triggers(activity)
|
||||
if not triggers:
|
||||
return 0
|
||||
chain = activity.metadata.get("_automation_chain") or []
|
||||
queryset = TaskAutomation.objects.filter(
|
||||
kind=TaskAutomationKind.EVENT,
|
||||
trigger__in=triggers,
|
||||
is_active=True,
|
||||
).filter(Q(applies_to_all_boards=True) | Q(boards=activity.task.board_id)).distinct()
|
||||
count = 0
|
||||
for automation in queryset:
|
||||
run = execute_automation(
|
||||
automation,
|
||||
activity.task,
|
||||
source="event",
|
||||
actor=activity.actor,
|
||||
trigger_activity=activity,
|
||||
idempotency_key=f"event:{automation.id}:{activity.id}",
|
||||
chain=chain,
|
||||
context={"triggers": sorted(triggers)},
|
||||
)
|
||||
if run:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _aware_datetime(value):
|
||||
if not value:
|
||||
return None
|
||||
parsed = parse_datetime(str(value)) if not isinstance(value, datetime) else value
|
||||
if not parsed:
|
||||
return None
|
||||
if timezone.is_naive(parsed):
|
||||
return timezone.make_aware(parsed, timezone.get_current_timezone())
|
||||
return parsed
|
||||
|
||||
|
||||
def _schedule_run_at(automation, task):
|
||||
config = automation.schedule_config or {}
|
||||
if config.get("mode", "task_due_date") == "fixed_datetime":
|
||||
return _aware_datetime(config.get("at"))
|
||||
if not task.due_date:
|
||||
return None
|
||||
days_before = max(0, int(config.get("days_before", 0)))
|
||||
run_time = parse_time(str(config.get("time", "09:00")))
|
||||
if not run_time:
|
||||
return None
|
||||
run_date = task.due_date - timedelta(days=days_before)
|
||||
return timezone.make_aware(
|
||||
datetime.combine(run_date, run_time),
|
||||
timezone.get_current_timezone(),
|
||||
)
|
||||
|
||||
|
||||
def dispatch_scheduled_automations(now=None):
|
||||
now = now or timezone.now()
|
||||
total = 0
|
||||
automations = TaskAutomation.objects.filter(
|
||||
kind=TaskAutomationKind.SCHEDULE,
|
||||
trigger=TaskAutomationTrigger.DATE_REACHED,
|
||||
is_active=True,
|
||||
).prefetch_related("boards")
|
||||
for automation in automations:
|
||||
tasks = Task.objects.select_related("board", "column", "created_by", "assignee")
|
||||
if not automation.applies_to_all_boards:
|
||||
tasks = tasks.filter(board__in=automation.boards.all())
|
||||
if (automation.schedule_config or {}).get("mode", "task_due_date") == "task_due_date":
|
||||
tasks = tasks.filter(due_date__isnull=False)
|
||||
for task in tasks.iterator():
|
||||
run_at = _schedule_run_at(automation, task)
|
||||
if not run_at or run_at > now or run_at < automation.created_at:
|
||||
continue
|
||||
key = f"schedule:{automation.id}:{task.id}:{run_at.isoformat()}"
|
||||
if TaskAutomationRun.objects.filter(idempotency_key=key).exists():
|
||||
continue
|
||||
if execute_automation(
|
||||
automation,
|
||||
task,
|
||||
source="schedule",
|
||||
idempotency_key=key,
|
||||
context={"scheduled_for": run_at.isoformat()},
|
||||
):
|
||||
total += 1
|
||||
return total
|
||||
216
backend/tasks/automation/schema.py
Normal file
216
backend/tasks/automation/schema.py
Normal file
@ -0,0 +1,216 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time
|
||||
|
||||
from django.utils.dateparse import parse_datetime, parse_time
|
||||
|
||||
from tasks.models import TaskAutomationKind, TaskAutomationTrigger, TaskPriority
|
||||
|
||||
|
||||
class AutomationSchemaError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
CONDITION_FIELDS = {
|
||||
"board_id": "Доска",
|
||||
"column_id": "Колонка",
|
||||
"column_name": "Название колонки",
|
||||
"assignee_id": "Исполнитель",
|
||||
"creator_id": "Создатель",
|
||||
"participant_ids": "Участники",
|
||||
"label_ids": "Метки",
|
||||
"priority": "Срочность",
|
||||
"due_date": "Срок",
|
||||
"completed": "Завершена",
|
||||
"title": "Название",
|
||||
"description": "Описание",
|
||||
"has_attachments": "Есть файлы",
|
||||
"checklist_complete": "Чек-лист выполнен",
|
||||
}
|
||||
|
||||
CONDITION_OPERATORS = {
|
||||
"eq": "Равно",
|
||||
"neq": "Не равно",
|
||||
"in": "Одно из",
|
||||
"not_in": "Не входит",
|
||||
"contains": "Содержит",
|
||||
"not_contains": "Не содержит",
|
||||
"is_empty": "Не заполнено",
|
||||
"not_empty": "Заполнено",
|
||||
"gt": "Больше",
|
||||
"gte": "Больше или равно",
|
||||
"lt": "Меньше",
|
||||
"lte": "Меньше или равно",
|
||||
"is_today": "Сегодня",
|
||||
"is_past": "Просрочено",
|
||||
"is_future": "В будущем",
|
||||
}
|
||||
|
||||
ACTION_TYPES = {
|
||||
"move": "Переместить задачу",
|
||||
"set_assignee": "Назначить исполнителя",
|
||||
"add_participant": "Добавить участника",
|
||||
"remove_participant": "Убрать участника",
|
||||
"add_label": "Добавить метку",
|
||||
"remove_label": "Убрать метку",
|
||||
"set_priority": "Изменить срочность",
|
||||
"set_due_date": "Изменить срок",
|
||||
"add_comment": "Добавить комментарий",
|
||||
"add_checklist": "Добавить чек-лист",
|
||||
"complete": "Завершить задачу",
|
||||
}
|
||||
|
||||
EVENT_TRIGGERS = {
|
||||
value: label
|
||||
for value, label in TaskAutomationTrigger.choices
|
||||
if value not in {
|
||||
TaskAutomationTrigger.MANUAL,
|
||||
TaskAutomationTrigger.DATE_REACHED,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def empty_condition_tree():
|
||||
return {"operator": "and", "children": []}
|
||||
|
||||
|
||||
def validate_condition_tree(node, *, depth=0):
|
||||
if node in (None, {}, []):
|
||||
return empty_condition_tree()
|
||||
if not isinstance(node, dict):
|
||||
raise AutomationSchemaError("Условия должны быть объектом.")
|
||||
if depth > 5:
|
||||
raise AutomationSchemaError("Допустимо не более пяти уровней условий.")
|
||||
|
||||
operator = node.get("operator")
|
||||
if operator in {"and", "or", "not"}:
|
||||
children = node.get("children", [])
|
||||
if not isinstance(children, list):
|
||||
raise AutomationSchemaError("Группа условий должна содержать список.")
|
||||
if len(children) > 50:
|
||||
raise AutomationSchemaError("В группе допустимо не более 50 условий.")
|
||||
if operator == "not" and len(children) != 1:
|
||||
raise AutomationSchemaError("Группа НЕ должна содержать одно условие.")
|
||||
return {
|
||||
"operator": operator,
|
||||
"children": [
|
||||
validate_condition_tree(child, depth=depth + 1)
|
||||
for child in children
|
||||
],
|
||||
}
|
||||
|
||||
field = node.get("field")
|
||||
if field not in CONDITION_FIELDS:
|
||||
raise AutomationSchemaError(f"Неизвестное поле условия: {field}.")
|
||||
if operator not in CONDITION_OPERATORS:
|
||||
raise AutomationSchemaError(f"Неизвестный оператор условия: {operator}.")
|
||||
return {"field": field, "operator": operator, "value": node.get("value")}
|
||||
|
||||
|
||||
def validate_actions(actions):
|
||||
if not isinstance(actions, list) or not actions:
|
||||
raise AutomationSchemaError("Добавьте хотя бы одно действие.")
|
||||
if len(actions) > 30:
|
||||
raise AutomationSchemaError("Допустимо не более 30 действий.")
|
||||
|
||||
normalized = []
|
||||
for raw_action in actions:
|
||||
if not isinstance(raw_action, dict):
|
||||
raise AutomationSchemaError("Действие должно быть объектом.")
|
||||
action = dict(raw_action)
|
||||
action_type = action.get("type")
|
||||
if action_type not in ACTION_TYPES:
|
||||
raise AutomationSchemaError(f"Неизвестное действие: {action_type}.")
|
||||
|
||||
if action_type == "move":
|
||||
target = action.get("target", "column")
|
||||
if target not in {"column", "column_name", "first", "final"}:
|
||||
raise AutomationSchemaError("Некорректная целевая колонка.")
|
||||
if target == "column" and not action.get("column_id"):
|
||||
raise AutomationSchemaError("Укажите колонку.")
|
||||
if target == "column_name" and not str(action.get("column_name", "")).strip():
|
||||
raise AutomationSchemaError("Укажите название колонки.")
|
||||
elif action_type in {"set_assignee", "add_participant", "remove_participant"}:
|
||||
if action_type != "set_assignee" and not action.get("user_id"):
|
||||
raise AutomationSchemaError("Укажите сотрудника.")
|
||||
elif action_type in {"add_label", "remove_label"} and not action.get("label_id"):
|
||||
raise AutomationSchemaError("Укажите метку.")
|
||||
elif action_type == "set_priority" and action.get("priority") not in TaskPriority.values:
|
||||
raise AutomationSchemaError("Некорректная срочность.")
|
||||
elif action_type == "set_due_date":
|
||||
mode = action.get("mode", "clear")
|
||||
if mode not in {"clear", "fixed", "relative"}:
|
||||
raise AutomationSchemaError("Некорректный режим срока.")
|
||||
if mode == "fixed" and not action.get("date"):
|
||||
raise AutomationSchemaError("Укажите дату срока.")
|
||||
if mode == "relative":
|
||||
try:
|
||||
int(action.get("days", 0))
|
||||
except (TypeError, ValueError) as error:
|
||||
raise AutomationSchemaError("Смещение срока должно быть числом.") from error
|
||||
elif action_type == "add_comment" and not str(action.get("text", "")).strip():
|
||||
raise AutomationSchemaError("Введите текст комментария.")
|
||||
elif action_type == "add_checklist":
|
||||
items = action.get("items")
|
||||
if not isinstance(items, list) or not any(str(item).strip() for item in items):
|
||||
raise AutomationSchemaError("Добавьте пункты чек-листа.")
|
||||
normalized.append(action)
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_schedule_config(config):
|
||||
if not isinstance(config, dict):
|
||||
raise AutomationSchemaError("Настройки срока должны быть объектом.")
|
||||
mode = config.get("mode", "task_due_date")
|
||||
if mode not in {"task_due_date", "fixed_datetime"}:
|
||||
raise AutomationSchemaError("Некорректный тип временного условия.")
|
||||
normalized = dict(config)
|
||||
normalized["mode"] = mode
|
||||
if mode == "task_due_date":
|
||||
try:
|
||||
normalized["days_before"] = max(0, int(config.get("days_before", 0)))
|
||||
except (TypeError, ValueError) as error:
|
||||
raise AutomationSchemaError("Количество дней должно быть числом.") from error
|
||||
run_time = parse_time(str(config.get("time", "09:00")))
|
||||
if not isinstance(run_time, time):
|
||||
raise AutomationSchemaError("Укажите корректное время запуска.")
|
||||
normalized["time"] = run_time.strftime("%H:%M")
|
||||
else:
|
||||
run_at = parse_datetime(str(config.get("at", "")))
|
||||
if not isinstance(run_at, datetime):
|
||||
raise AutomationSchemaError("Укажите корректную дату и время запуска.")
|
||||
normalized["at"] = run_at.isoformat()
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_automation_payload(*, kind, trigger, conditions, actions, schedule_config):
|
||||
if kind not in TaskAutomationKind.values:
|
||||
raise AutomationSchemaError("Некорректный тип автоматизации.")
|
||||
if trigger not in TaskAutomationTrigger.values:
|
||||
raise AutomationSchemaError("Некорректное событие автоматизации.")
|
||||
if kind == TaskAutomationKind.BUTTON and trigger != TaskAutomationTrigger.MANUAL:
|
||||
raise AutomationSchemaError("Кнопка должна иметь ручной запуск.")
|
||||
if kind == TaskAutomationKind.SCHEDULE and trigger != TaskAutomationTrigger.DATE_REACHED:
|
||||
raise AutomationSchemaError("Автоматизация по сроку должна запускаться по дате.")
|
||||
if kind == TaskAutomationKind.EVENT and trigger not in EVENT_TRIGGERS:
|
||||
raise AutomationSchemaError("Выберите событие автоматизации.")
|
||||
return {
|
||||
"conditions": validate_condition_tree(conditions),
|
||||
"actions": validate_actions(actions),
|
||||
"schedule_config": (
|
||||
validate_schedule_config(schedule_config)
|
||||
if kind == TaskAutomationKind.SCHEDULE
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def automation_catalog():
|
||||
return {
|
||||
"kinds": dict(TaskAutomationKind.choices),
|
||||
"event_triggers": EVENT_TRIGGERS,
|
||||
"condition_fields": CONDITION_FIELDS,
|
||||
"condition_operators": CONDITION_OPERATORS,
|
||||
"action_types": ACTION_TYPES,
|
||||
"priorities": dict(TaskPriority.choices),
|
||||
}
|
||||
73
backend/tasks/comment_resources.py
Normal file
73
backend/tasks/comment_resources.py
Normal file
@ -0,0 +1,73 @@
|
||||
import re
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from tasks.models import TaskExternalLink
|
||||
|
||||
|
||||
HTTP_LINK_PATTERN = re.compile(r"https?://[^\s<>\"']+", re.IGNORECASE)
|
||||
TRAILING_LINK_PUNCTUATION = ".,;:!?)]}"
|
||||
|
||||
|
||||
def normalize_http_link(value):
|
||||
candidate = str(value or "").strip().rstrip(TRAILING_LINK_PUNCTUATION)
|
||||
if not candidate or len(candidate) > 2048:
|
||||
return None
|
||||
try:
|
||||
parsed = urlsplit(candidate)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname:
|
||||
return None
|
||||
return urlunsplit(
|
||||
(
|
||||
parsed.scheme.lower(),
|
||||
parsed.netloc.lower(),
|
||||
parsed.path,
|
||||
parsed.query,
|
||||
parsed.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def http_link_identity(value):
|
||||
normalized = normalize_http_link(value)
|
||||
if normalized is None:
|
||||
return None
|
||||
parsed = urlsplit(normalized)
|
||||
path = "" if parsed.path == "/" else parsed.path
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, path, parsed.query, parsed.fragment))
|
||||
|
||||
|
||||
def extract_http_links(text):
|
||||
links = []
|
||||
identities = set()
|
||||
for match in HTTP_LINK_PATTERN.finditer(text or ""):
|
||||
link = normalize_http_link(match.group(0))
|
||||
identity = http_link_identity(link)
|
||||
if link is None or identity is None or identity in identities:
|
||||
continue
|
||||
identities.add(identity)
|
||||
links.append(link)
|
||||
return links
|
||||
|
||||
|
||||
def add_comment_links_to_task(*, task, text, created_by):
|
||||
existing_identities = {
|
||||
identity
|
||||
for url in task.external_links.values_list("url", flat=True)
|
||||
if (identity := http_link_identity(url)) is not None
|
||||
}
|
||||
created = []
|
||||
for url in extract_http_links(text):
|
||||
identity = http_link_identity(url)
|
||||
if identity in existing_identities:
|
||||
continue
|
||||
link, was_created = TaskExternalLink.objects.get_or_create(
|
||||
task=task,
|
||||
url=url,
|
||||
defaults={"created_by": created_by},
|
||||
)
|
||||
existing_identities.add(identity)
|
||||
if was_created:
|
||||
created.append(link)
|
||||
return created
|
||||
@ -0,0 +1,86 @@
|
||||
# Generated by Django 5.2.4 on 2026-08-06 08:55
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0030_global_task_labels'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TaskAutomation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=160, verbose_name='Название')),
|
||||
('description', models.TextField(blank=True, verbose_name='Описание')),
|
||||
('kind', models.CharField(choices=[('event', 'Событие'), ('schedule', 'Срок'), ('button', 'Кнопка')], default='event', max_length=16, verbose_name='Тип')),
|
||||
('trigger', models.CharField(choices=[('manual', 'Ручной запуск'), ('task_created', 'Задача создана'), ('task_updated', 'Задача изменена'), ('task_moved', 'Задача перемещена'), ('label_added', 'Метка добавлена'), ('label_removed', 'Метка удалена'), ('assignee_changed', 'Исполнитель изменён'), ('due_date_changed', 'Срок изменён'), ('priority_changed', 'Срочность изменена'), ('attachment_added', 'Файл добавлен'), ('comment_added', 'Комментарий добавлен'), ('checklist_completed', 'Пункт чек-листа выполнен'), ('linked_object_added', 'Связанный объект добавлен'), ('date_reached', 'Наступила дата')], default='task_updated', max_length=32, verbose_name='Событие')),
|
||||
('conditions', models.JSONField(blank=True, default=dict, verbose_name='Условия')),
|
||||
('actions', models.JSONField(blank=True, default=list, verbose_name='Действия')),
|
||||
('schedule_config', models.JSONField(blank=True, default=dict, verbose_name='Настройки срока')),
|
||||
('applies_to_all_boards', models.BooleanField(default=True, verbose_name='Для всех досок')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Активна')),
|
||||
('stop_on_error', models.BooleanField(default=True, verbose_name='Остановить при ошибке')),
|
||||
('position', models.PositiveIntegerField(default=0, verbose_name='Позиция')),
|
||||
('last_run_at', models.DateTimeField(blank=True, null=True, verbose_name='Последний запуск')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
|
||||
('boards', models.ManyToManyField(blank=True, related_name='automations', to='tasks.taskboard', verbose_name='Доски')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_task_automations', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Автоматизация задач',
|
||||
'verbose_name_plural': 'Автоматизации задач',
|
||||
'ordering': ['kind', 'position', 'name', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TaskAutomationRun',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('source', models.CharField(max_length=16, verbose_name='Источник')),
|
||||
('status', models.CharField(choices=[('running', 'Выполняется'), ('success', 'Выполнено'), ('partial', 'Выполнено частично'), ('failed', 'Ошибка'), ('skipped', 'Пропущено')], default='running', max_length=16, verbose_name='Статус')),
|
||||
('idempotency_key', models.CharField(max_length=255, unique=True, verbose_name='Ключ идемпотентности')),
|
||||
('context', models.JSONField(blank=True, default=dict, verbose_name='Контекст')),
|
||||
('actions_log', models.JSONField(blank=True, default=list, verbose_name='Журнал действий')),
|
||||
('error', models.TextField(blank=True, verbose_name='Ошибка')),
|
||||
('started_at', models.DateTimeField(auto_now_add=True, verbose_name='Начато')),
|
||||
('finished_at', models.DateTimeField(blank=True, null=True, verbose_name='Завершено')),
|
||||
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='task_automation_runs', to=settings.AUTH_USER_MODEL, verbose_name='Запустил')),
|
||||
('automation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='runs', to='tasks.taskautomation', verbose_name='Автоматизация')),
|
||||
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='automation_runs', to='tasks.task', verbose_name='Задача')),
|
||||
('trigger_activity', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='automation_runs', to='tasks.taskactivity', verbose_name='Событие')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Запуск автоматизации задач',
|
||||
'verbose_name_plural': 'Запуски автоматизаций задач',
|
||||
'ordering': ['-started_at', '-id'],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='taskautomation',
|
||||
index=models.Index(fields=['kind', 'is_active', 'position'], name='tasks_taska_kind_d0634f_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='taskautomation',
|
||||
index=models.Index(fields=['trigger', 'is_active'], name='tasks_taska_trigger_90116c_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='taskautomationrun',
|
||||
index=models.Index(fields=['automation', '-started_at'], name='tasks_taska_automat_dfdf67_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='taskautomationrun',
|
||||
index=models.Index(fields=['task', '-started_at'], name='tasks_taska_task_id_c3fe8f_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='taskautomationrun',
|
||||
index=models.Index(fields=['status', '-started_at'], name='tasks_taska_status_4e0872_idx'),
|
||||
),
|
||||
]
|
||||
20
backend/tasks/migrations/0032_taskattachment_comment.py
Normal file
20
backend/tasks/migrations/0032_taskattachment_comment.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.4 on 2026-08-06 09:41
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('communications', '0039_alter_chat_context_object_id_bigint'),
|
||||
('tasks', '0031_taskautomation_taskautomationrun_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='taskattachment',
|
||||
name='comment',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='task_attachments', to='communications.message', verbose_name='Комментарий'),
|
||||
),
|
||||
]
|
||||
@ -14,6 +14,37 @@ class TaskPriority(models.TextChoices):
|
||||
CRITICAL = "critical", "Критический"
|
||||
|
||||
|
||||
class TaskAutomationKind(models.TextChoices):
|
||||
EVENT = "event", "Событие"
|
||||
SCHEDULE = "schedule", "Срок"
|
||||
BUTTON = "button", "Кнопка"
|
||||
|
||||
|
||||
class TaskAutomationTrigger(models.TextChoices):
|
||||
MANUAL = "manual", "Ручной запуск"
|
||||
TASK_CREATED = "task_created", "Задача создана"
|
||||
TASK_UPDATED = "task_updated", "Задача изменена"
|
||||
TASK_MOVED = "task_moved", "Задача перемещена"
|
||||
LABEL_ADDED = "label_added", "Метка добавлена"
|
||||
LABEL_REMOVED = "label_removed", "Метка удалена"
|
||||
ASSIGNEE_CHANGED = "assignee_changed", "Исполнитель изменён"
|
||||
DUE_DATE_CHANGED = "due_date_changed", "Срок изменён"
|
||||
PRIORITY_CHANGED = "priority_changed", "Срочность изменена"
|
||||
ATTACHMENT_ADDED = "attachment_added", "Файл добавлен"
|
||||
COMMENT_ADDED = "comment_added", "Комментарий добавлен"
|
||||
CHECKLIST_COMPLETED = "checklist_completed", "Пункт чек-листа выполнен"
|
||||
LINKED_OBJECT_ADDED = "linked_object_added", "Связанный объект добавлен"
|
||||
DATE_REACHED = "date_reached", "Наступила дата"
|
||||
|
||||
|
||||
class TaskAutomationRunStatus(models.TextChoices):
|
||||
RUNNING = "running", "Выполняется"
|
||||
SUCCESS = "success", "Выполнено"
|
||||
PARTIAL = "partial", "Выполнено частично"
|
||||
FAILED = "failed", "Ошибка"
|
||||
SKIPPED = "skipped", "Пропущено"
|
||||
|
||||
|
||||
class TaskBoardAccessScope(models.TextChoices):
|
||||
ALL = "all", "Для всех"
|
||||
PRIVATE = "private", "Для себя"
|
||||
@ -362,6 +393,136 @@ class Task(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class TaskAutomation(models.Model):
|
||||
name = models.CharField("Название", max_length=160)
|
||||
description = models.TextField("Описание", blank=True)
|
||||
kind = models.CharField(
|
||||
"Тип",
|
||||
max_length=16,
|
||||
choices=TaskAutomationKind.choices,
|
||||
default=TaskAutomationKind.EVENT,
|
||||
)
|
||||
trigger = models.CharField(
|
||||
"Событие",
|
||||
max_length=32,
|
||||
choices=TaskAutomationTrigger.choices,
|
||||
default=TaskAutomationTrigger.TASK_UPDATED,
|
||||
)
|
||||
conditions = models.JSONField(
|
||||
"Условия",
|
||||
default=dict,
|
||||
blank=True,
|
||||
)
|
||||
actions = models.JSONField(
|
||||
"Действия",
|
||||
default=list,
|
||||
blank=True,
|
||||
)
|
||||
schedule_config = models.JSONField(
|
||||
"Настройки срока",
|
||||
default=dict,
|
||||
blank=True,
|
||||
)
|
||||
applies_to_all_boards = models.BooleanField(
|
||||
"Для всех досок",
|
||||
default=True,
|
||||
)
|
||||
boards = models.ManyToManyField(
|
||||
TaskBoard,
|
||||
blank=True,
|
||||
related_name="automations",
|
||||
verbose_name="Доски",
|
||||
)
|
||||
is_active = models.BooleanField("Активна", default=True)
|
||||
stop_on_error = models.BooleanField("Остановить при ошибке", default=True)
|
||||
position = models.PositiveIntegerField("Позиция", default=0)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="created_task_automations",
|
||||
verbose_name="Создал",
|
||||
)
|
||||
last_run_at = models.DateTimeField("Последний запуск", null=True, blank=True)
|
||||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Автоматизация задач"
|
||||
verbose_name_plural = "Автоматизации задач"
|
||||
ordering = ["kind", "position", "name", "id"]
|
||||
indexes = [
|
||||
models.Index(fields=["kind", "is_active", "position"]),
|
||||
models.Index(fields=["trigger", "is_active"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def applies_to_board(self, board_id):
|
||||
return self.applies_to_all_boards or self.boards.filter(id=board_id).exists()
|
||||
|
||||
|
||||
class TaskAutomationRun(models.Model):
|
||||
automation = models.ForeignKey(
|
||||
TaskAutomation,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="runs",
|
||||
verbose_name="Автоматизация",
|
||||
)
|
||||
task = models.ForeignKey(
|
||||
Task,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="automation_runs",
|
||||
verbose_name="Задача",
|
||||
)
|
||||
trigger_activity = models.ForeignKey(
|
||||
"TaskActivity",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="automation_runs",
|
||||
verbose_name="Событие",
|
||||
)
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="task_automation_runs",
|
||||
verbose_name="Запустил",
|
||||
)
|
||||
source = models.CharField("Источник", max_length=16)
|
||||
status = models.CharField(
|
||||
"Статус",
|
||||
max_length=16,
|
||||
choices=TaskAutomationRunStatus.choices,
|
||||
default=TaskAutomationRunStatus.RUNNING,
|
||||
)
|
||||
idempotency_key = models.CharField(
|
||||
"Ключ идемпотентности",
|
||||
max_length=255,
|
||||
unique=True,
|
||||
)
|
||||
context = models.JSONField("Контекст", default=dict, blank=True)
|
||||
actions_log = models.JSONField("Журнал действий", default=list, blank=True)
|
||||
error = models.TextField("Ошибка", blank=True)
|
||||
started_at = models.DateTimeField("Начато", auto_now_add=True)
|
||||
finished_at = models.DateTimeField("Завершено", null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Запуск автоматизации задач"
|
||||
verbose_name_plural = "Запуски автоматизаций задач"
|
||||
ordering = ["-started_at", "-id"]
|
||||
indexes = [
|
||||
models.Index(fields=["automation", "-started_at"]),
|
||||
models.Index(fields=["task", "-started_at"]),
|
||||
models.Index(fields=["status", "-started_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.automation}: {self.task} ({self.status})"
|
||||
|
||||
|
||||
class TaskAttachment(models.Model):
|
||||
task = models.ForeignKey(
|
||||
Task,
|
||||
@ -369,6 +530,14 @@ class TaskAttachment(models.Model):
|
||||
related_name="attachments",
|
||||
verbose_name="Задача",
|
||||
)
|
||||
comment = models.ForeignKey(
|
||||
"communications.Message",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="task_attachments",
|
||||
verbose_name="Комментарий",
|
||||
)
|
||||
file = models.FileField(
|
||||
"Файл",
|
||||
upload_to="task_attachments/%Y/%m/%d/",
|
||||
|
||||
@ -7,7 +7,7 @@ from django.dispatch import receiver
|
||||
|
||||
from common.image_utils import compress_avatar
|
||||
|
||||
from .models import Task, TaskAttachment, TaskBoard
|
||||
from .models import Task, TaskActivity, TaskAttachment, TaskBoard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -71,3 +71,18 @@ def ensure_task_core_participants(sender, instance, **kwargs):
|
||||
@receiver(post_delete, sender=TaskAttachment)
|
||||
def delete_task_attachment_file(sender, instance, **kwargs):
|
||||
_delete_file_after_commit(instance.file)
|
||||
|
||||
|
||||
@receiver(post_save, sender=TaskActivity)
|
||||
def dispatch_task_automation_event(sender, instance, created, **kwargs):
|
||||
if not created:
|
||||
return
|
||||
|
||||
activity_id = instance.id
|
||||
|
||||
def dispatch():
|
||||
from tasks.tasks import process_task_automation_event_task
|
||||
|
||||
process_task_automation_event_task.delay(activity_id)
|
||||
|
||||
transaction.on_commit(dispatch)
|
||||
|
||||
@ -1,8 +1,22 @@
|
||||
from celery import shared_task
|
||||
|
||||
from tasks.automation import (
|
||||
dispatch_scheduled_automations,
|
||||
process_activity_automations,
|
||||
)
|
||||
from tasks.notifications.handlers import dispatch_task_due_notifications
|
||||
|
||||
|
||||
@shared_task(name="tasks.dispatch_task_due_notifications")
|
||||
def dispatch_task_due_notifications_task():
|
||||
return dispatch_task_due_notifications()
|
||||
|
||||
|
||||
@shared_task(name="tasks.process_task_automation_event")
|
||||
def process_task_automation_event_task(activity_id):
|
||||
return process_activity_automations(activity_id)
|
||||
|
||||
|
||||
@shared_task(name="tasks.dispatch_scheduled_automations")
|
||||
def dispatch_scheduled_automations_task():
|
||||
return dispatch_scheduled_automations()
|
||||
|
||||
279
backend/tests/api/v1/tasks/test_task_automations.py
Normal file
279
backend/tests/api/v1/tasks/test_task_automations.py
Normal file
@ -0,0 +1,279 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from tasks.automation.engine import (
|
||||
dispatch_scheduled_automations,
|
||||
process_activity_automations,
|
||||
)
|
||||
from tasks.models import (
|
||||
Task,
|
||||
TaskActivity,
|
||||
TaskActivityAction,
|
||||
TaskAutomation,
|
||||
TaskAutomationKind,
|
||||
TaskAutomationRun,
|
||||
TaskAutomationRunStatus,
|
||||
TaskAutomationTrigger,
|
||||
TaskBoard,
|
||||
TaskColumn,
|
||||
TaskLabel,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def task_setup(user_factory):
|
||||
owner = user_factory(staff=True, email="automation-admin@example.com")
|
||||
board = TaskBoard.objects.create(name="Автоматизация", created_by=owner)
|
||||
new_column = TaskColumn.objects.create(
|
||||
board=board,
|
||||
name="Новые",
|
||||
position=1000,
|
||||
)
|
||||
done_column = TaskColumn.objects.create(
|
||||
board=board,
|
||||
name="Готово",
|
||||
position=2000,
|
||||
is_done=True,
|
||||
)
|
||||
task = Task.objects.create(
|
||||
board=board,
|
||||
column=new_column,
|
||||
title="Проверить автоматизацию",
|
||||
priority="medium",
|
||||
created_by=owner,
|
||||
)
|
||||
return owner, board, new_column, done_column, task
|
||||
|
||||
|
||||
def test_admin_creates_global_automation_with_nested_conditions(
|
||||
api_client,
|
||||
task_setup,
|
||||
):
|
||||
owner, board, _, _, _ = task_setup
|
||||
api_client.force_authenticate(owner)
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:v1:tasks:task-automation-list"),
|
||||
{
|
||||
"name": "Критические задачи",
|
||||
"kind": "event",
|
||||
"trigger": "task_updated",
|
||||
"conditions": {
|
||||
"operator": "or",
|
||||
"children": [
|
||||
{
|
||||
"operator": "and",
|
||||
"children": [
|
||||
{"field": "priority", "operator": "eq", "value": "critical"},
|
||||
{"field": "completed", "operator": "eq", "value": False},
|
||||
],
|
||||
},
|
||||
{
|
||||
"operator": "and",
|
||||
"children": [
|
||||
{"field": "board_id", "operator": "eq", "value": board.id},
|
||||
{"field": "title", "operator": "contains", "value": "срочно"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"actions": [{"type": "set_priority", "priority": "critical"}],
|
||||
"applies_to_all_boards": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["conditions"]["operator"] == "or"
|
||||
assert response.data["boards"] == []
|
||||
|
||||
|
||||
def test_event_automation_runs_actions_in_order_and_is_idempotent(task_setup):
|
||||
owner, _, _, done_column, task = task_setup
|
||||
initial_label = TaskLabel.objects.create(name="Входящая", color="#38bdf8")
|
||||
result_label = TaskLabel.objects.create(name="Обработана", color="#22c55e")
|
||||
task.labels.add(initial_label)
|
||||
automation = TaskAutomation.objects.create(
|
||||
name="Закрыть входящую",
|
||||
kind=TaskAutomationKind.EVENT,
|
||||
trigger=TaskAutomationTrigger.TASK_CREATED,
|
||||
conditions={
|
||||
"operator": "or",
|
||||
"children": [
|
||||
{
|
||||
"operator": "and",
|
||||
"children": [
|
||||
{"field": "priority", "operator": "eq", "value": "medium"},
|
||||
{"field": "label_ids", "operator": "contains", "value": initial_label.id},
|
||||
],
|
||||
},
|
||||
{
|
||||
"operator": "and",
|
||||
"children": [
|
||||
{"field": "priority", "operator": "eq", "value": "critical"},
|
||||
{"field": "completed", "operator": "eq", "value": False},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
actions=[
|
||||
{"type": "add_label", "label_id": result_label.id},
|
||||
{"type": "set_priority", "priority": "high"},
|
||||
{"type": "complete"},
|
||||
],
|
||||
created_by=owner,
|
||||
)
|
||||
activity = TaskActivity.objects.create(
|
||||
task=task,
|
||||
actor=owner,
|
||||
action=TaskActivityAction.CREATED,
|
||||
)
|
||||
|
||||
assert process_activity_automations(activity.id) == 1
|
||||
assert process_activity_automations(activity.id) == 1
|
||||
|
||||
task.refresh_from_db()
|
||||
run = TaskAutomationRun.objects.get(automation=automation, task=task)
|
||||
assert task.labels.filter(id=result_label.id).exists()
|
||||
assert task.priority == "high"
|
||||
assert task.column_id == done_column.id
|
||||
assert run.status == TaskAutomationRunStatus.SUCCESS
|
||||
assert [item["type"] for item in run.actions_log] == [
|
||||
"add_label",
|
||||
"set_priority",
|
||||
"complete",
|
||||
]
|
||||
assert TaskAutomationRun.objects.filter(automation=automation, task=task).count() == 1
|
||||
|
||||
|
||||
def test_scheduled_automation_runs_once_at_fixed_datetime(task_setup):
|
||||
owner, _, _, _, task = task_setup
|
||||
run_at = timezone.now() + timedelta(minutes=5)
|
||||
automation = TaskAutomation.objects.create(
|
||||
name="Проверка срока",
|
||||
kind=TaskAutomationKind.SCHEDULE,
|
||||
trigger=TaskAutomationTrigger.DATE_REACHED,
|
||||
conditions={
|
||||
"operator": "and",
|
||||
"children": [
|
||||
{"field": "title", "operator": "contains", "value": "автоматизацию"}
|
||||
],
|
||||
},
|
||||
actions=[{"type": "set_priority", "priority": "critical"}],
|
||||
schedule_config={"mode": "fixed_datetime", "at": run_at.isoformat()},
|
||||
created_by=owner,
|
||||
)
|
||||
|
||||
assert dispatch_scheduled_automations(now=run_at - timedelta(seconds=1)) == 0
|
||||
assert dispatch_scheduled_automations(now=run_at + timedelta(seconds=1)) == 1
|
||||
assert dispatch_scheduled_automations(now=run_at + timedelta(hours=1)) == 0
|
||||
|
||||
task.refresh_from_db()
|
||||
assert task.priority == "critical"
|
||||
assert TaskAutomationRun.objects.filter(automation=automation, task=task).count() == 1
|
||||
|
||||
|
||||
def test_scheduled_automation_runs_relative_to_task_due_date(task_setup):
|
||||
owner, board, _, _, task = task_setup
|
||||
local_run_at = timezone.localtime(timezone.now() + timedelta(minutes=5)).replace(
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
task.due_date = local_run_at.date() + timedelta(days=2)
|
||||
task.save(update_fields=["due_date", "updated_at"])
|
||||
automation = TaskAutomation.objects.create(
|
||||
name="За два дня до срока",
|
||||
kind=TaskAutomationKind.SCHEDULE,
|
||||
trigger=TaskAutomationTrigger.DATE_REACHED,
|
||||
conditions={"operator": "and", "children": []},
|
||||
actions=[{"type": "set_priority", "priority": "high"}],
|
||||
schedule_config={
|
||||
"mode": "task_due_date",
|
||||
"days_before": 2,
|
||||
"time": local_run_at.strftime("%H:%M"),
|
||||
},
|
||||
applies_to_all_boards=False,
|
||||
created_by=owner,
|
||||
)
|
||||
automation.boards.add(board)
|
||||
|
||||
assert dispatch_scheduled_automations(now=local_run_at - timedelta(seconds=1)) == 0
|
||||
assert dispatch_scheduled_automations(now=local_run_at + timedelta(seconds=1)) == 1
|
||||
assert dispatch_scheduled_automations(now=local_run_at + timedelta(days=1)) == 0
|
||||
|
||||
task.refresh_from_db()
|
||||
assert task.priority == "high"
|
||||
run = TaskAutomationRun.objects.get(automation=automation, task=task)
|
||||
assert run.context["scheduled_for"] == local_run_at.isoformat()
|
||||
|
||||
|
||||
def test_regular_user_can_run_eligible_button_but_cannot_manage_rules(
|
||||
api_client,
|
||||
user_factory,
|
||||
task_setup,
|
||||
):
|
||||
owner, board, _, _, task = task_setup
|
||||
employee = user_factory(email="automation-user@example.com")
|
||||
board.members.add(employee)
|
||||
button = TaskAutomation.objects.create(
|
||||
name="Сделать срочной",
|
||||
kind=TaskAutomationKind.BUTTON,
|
||||
trigger=TaskAutomationTrigger.MANUAL,
|
||||
conditions={"operator": "and", "children": []},
|
||||
actions=[{"type": "set_priority", "priority": "high"}],
|
||||
created_by=owner,
|
||||
)
|
||||
api_client.force_authenticate(employee)
|
||||
|
||||
list_response = api_client.get(reverse("api:v1:tasks:task-automation-list"))
|
||||
buttons_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-automation-buttons"),
|
||||
{"task": task.id},
|
||||
)
|
||||
run_response = api_client.post(
|
||||
reverse("api:v1:tasks:task-automation-run", kwargs={"pk": button.id}),
|
||||
{"task": task.id},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert list_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert buttons_response.status_code == status.HTTP_200_OK
|
||||
assert [item["id"] for item in buttons_response.data] == [button.id]
|
||||
assert run_response.status_code == status.HTTP_200_OK
|
||||
task.refresh_from_db()
|
||||
assert task.priority == "high"
|
||||
|
||||
|
||||
def test_manual_button_rechecks_conditions_on_run(api_client, user_factory, task_setup):
|
||||
owner, board, _, _, task = task_setup
|
||||
employee = user_factory(email="automation-condition@example.com")
|
||||
board.members.add(employee)
|
||||
button = TaskAutomation.objects.create(
|
||||
name="Только критические",
|
||||
kind=TaskAutomationKind.BUTTON,
|
||||
trigger=TaskAutomationTrigger.MANUAL,
|
||||
conditions={
|
||||
"field": "priority",
|
||||
"operator": "eq",
|
||||
"value": "critical",
|
||||
},
|
||||
actions=[{"type": "complete"}],
|
||||
created_by=owner,
|
||||
)
|
||||
api_client.force_authenticate(employee)
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:v1:tasks:task-automation-run", kwargs={"pk": button.id}),
|
||||
{"task": task.id},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
task.refresh_from_db()
|
||||
assert task.completed_at is None
|
||||
@ -3110,3 +3110,92 @@ def test_task_comments_chat_access_follows_board_access(api_client):
|
||||
reverse("api:v1:chats-messages", kwargs={"pk": chat.id})
|
||||
)
|
||||
assert outsider_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_task_comment_files_and_links_become_task_resources(api_client, user):
|
||||
api_client.force_authenticate(user=user)
|
||||
board = TaskBoard.objects.create(name="Ресурсы комментария", created_by=user)
|
||||
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
|
||||
task = Task.objects.create(
|
||||
board=board,
|
||||
column=column,
|
||||
title="Задача с файлами в комментариях",
|
||||
created_by=user,
|
||||
)
|
||||
image = SimpleUploadedFile(
|
||||
"preview.png",
|
||||
base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
),
|
||||
content_type="image/png",
|
||||
)
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}),
|
||||
{
|
||||
"text": "Макет: https://Example.com/mockup). Повтор https://example.com/mockup",
|
||||
"files": [image],
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert len(response.data["attachments"]) == 1
|
||||
attachment_data = response.data["attachments"][0]
|
||||
attachment = TaskAttachment.objects.get(id=attachment_data["id"])
|
||||
assert attachment.task == task
|
||||
assert attachment.comment_id == response.data["id"]
|
||||
assert attachment.mime_type == "image/png"
|
||||
assert attachment_data["comment"] == response.data["id"]
|
||||
assert TaskExternalLink.objects.filter(
|
||||
task=task,
|
||||
url="https://example.com/mockup",
|
||||
).count() == 1
|
||||
|
||||
listed = api_client.get(
|
||||
reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id})
|
||||
)
|
||||
assert listed.status_code == status.HTTP_200_OK
|
||||
assert listed.data[0]["attachments"][0]["file_name"] == "preview.png"
|
||||
|
||||
delete_response = api_client.delete(
|
||||
reverse(
|
||||
"api:v1:tasks:task-delete-comment",
|
||||
kwargs={"pk": task.id, "comment_id": response.data["id"]},
|
||||
)
|
||||
)
|
||||
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
|
||||
attachment.refresh_from_db()
|
||||
assert attachment.comment_id is None
|
||||
assert attachment.file.storage.exists(attachment.file.name)
|
||||
|
||||
|
||||
def test_task_allows_file_only_comment(api_client, user):
|
||||
api_client.force_authenticate(user=user)
|
||||
board = TaskBoard.objects.create(name="Файл без текста", created_by=user)
|
||||
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
|
||||
task = Task.objects.create(
|
||||
board=board,
|
||||
column=column,
|
||||
title="Комментарий-файл",
|
||||
created_by=user,
|
||||
)
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}),
|
||||
{
|
||||
"text": "",
|
||||
"files": [
|
||||
SimpleUploadedFile(
|
||||
"notes.txt",
|
||||
b"details",
|
||||
content_type="text/plain",
|
||||
)
|
||||
],
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["text"] == ""
|
||||
assert response.data["attachments"][0]["file_name"] == "notes.txt"
|
||||
|
||||
@ -56,6 +56,7 @@ import {
|
||||
Trash2,
|
||||
UserPlus,
|
||||
UserRound,
|
||||
Workflow,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
@ -75,6 +76,8 @@ import { AppShell } from "@/components/AppShell";
|
||||
import TaskBoardAvatar from "@/components/tasks/TaskBoardAvatar";
|
||||
import TaskBoardNavigator from "@/components/tasks/TaskBoardNavigator";
|
||||
import TaskCoverImage from "@/components/tasks/TaskCoverImage";
|
||||
import TaskCommentAttachment from "@/components/tasks/TaskCommentAttachment";
|
||||
import TaskAutomationManager from "@/components/tasks/TaskAutomationManager";
|
||||
import { RequestAvatar } from "@/components/requests/RequestAvatar";
|
||||
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
|
||||
import { Modal } from "@/components/ui";
|
||||
@ -91,6 +94,7 @@ import type {
|
||||
Message,
|
||||
TaskActivity,
|
||||
TaskAttachment,
|
||||
TaskAutomation,
|
||||
TaskBoard,
|
||||
TaskBoardGroup,
|
||||
TaskBoardSummary,
|
||||
@ -435,6 +439,34 @@ function isImageFile(file: File): boolean {
|
||||
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(file.name);
|
||||
}
|
||||
|
||||
const TASK_COMMENT_URL_PATTERN = /(https?:\/\/[^\s<>"']+)/gi;
|
||||
const TASK_COMMENT_URL_TRAILING_PUNCTUATION = /[.,;:!?)}\]]+$/;
|
||||
|
||||
function TaskCommentText({ text }: { text: string }) {
|
||||
return (
|
||||
<p className="app-text-wrap whitespace-pre-wrap text-sm leading-5 text-[var(--foreground)]">
|
||||
{text.split(TASK_COMMENT_URL_PATTERN).map((part, index) => {
|
||||
if (!/^https?:\/\//i.test(part)) return part;
|
||||
const trailing = part.match(TASK_COMMENT_URL_TRAILING_PUNCTUATION)?.[0] || "";
|
||||
const href = trailing ? part.slice(0, -trailing.length) : part;
|
||||
return (
|
||||
<span key={`${href}:${index}`}>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-sky-400 underline decoration-sky-400/50 underline-offset-2 hover:text-sky-300"
|
||||
>
|
||||
{href}
|
||||
</a>
|
||||
{trailing}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function getMessageCreatedLabel(message?: Message | null) {
|
||||
if (!message) return "";
|
||||
return message.created || (message.created_at ? formatDate(message.created_at) : "");
|
||||
@ -2906,6 +2938,7 @@ function TasksPageContent() {
|
||||
const [boardModalOpen, setBoardModalOpen] = useState(false);
|
||||
const [boardGroupModalOpen, setBoardGroupModalOpen] = useState(false);
|
||||
const [boardActionsMenuOpen, setBoardActionsMenuOpen] = useState(false);
|
||||
const [automationModalOpen, setAutomationModalOpen] = useState(false);
|
||||
const [columnModalOpen, setColumnModalOpen] = useState(false);
|
||||
const [columnRowModalOpen, setColumnRowModalOpen] = useState(false);
|
||||
const [form, setForm] = useState<TaskFormState>(emptyForm);
|
||||
@ -2969,6 +3002,7 @@ function TasksPageContent() {
|
||||
const [editingCommentDraft, setEditingCommentDraft] = useState("");
|
||||
const [taskCommentsLoading, setTaskCommentsLoading] = useState(false);
|
||||
const [commentDraft, setCommentDraft] = useState("");
|
||||
const [commentFiles, setCommentFiles] = useState<File[]>([]);
|
||||
const [attachmentsOpen, setAttachmentsOpen] = useState(false);
|
||||
const [taskAttachments, setTaskAttachments] = useState<TaskAttachment[]>([]);
|
||||
const [taskAttachmentsLoading, setTaskAttachmentsLoading] = useState(false);
|
||||
@ -2980,8 +3014,12 @@ function TasksPageContent() {
|
||||
const [taskChecklistSaving, setTaskChecklistSaving] = useState(false);
|
||||
const [taskChecklistDraft, setTaskChecklistDraft] = useState("");
|
||||
const [activityOpen, setActivityOpen] = useState(false);
|
||||
const [taskDetailsOpen, setTaskDetailsOpen] = useState(false);
|
||||
const [taskActivities, setTaskActivities] = useState<TaskActivity[]>([]);
|
||||
const [taskActivityLoading, setTaskActivityLoading] = useState(false);
|
||||
const [taskAutomationButtons, setTaskAutomationButtons] = useState<TaskAutomation[]>([]);
|
||||
const [taskAutomationButtonsLoading, setTaskAutomationButtonsLoading] = useState(false);
|
||||
const [runningTaskAutomationId, setRunningTaskAutomationId] = useState<number | null>(null);
|
||||
const boardActionsMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const taskMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const taskAddMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -3249,6 +3287,37 @@ function TasksPageContent() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadTaskAutomationButtons = useCallback(async (taskId: number) => {
|
||||
setTaskAutomationButtonsLoading(true);
|
||||
try {
|
||||
setTaskAutomationButtons(await apiClient.getTaskAutomationButtons(taskId));
|
||||
} catch (automationError) {
|
||||
setTaskAutomationButtons([]);
|
||||
setError(getTaskError(automationError, "Не удалось загрузить действия задачи"));
|
||||
} finally {
|
||||
setTaskAutomationButtonsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runTaskAutomation = useCallback(async (
|
||||
automation: TaskAutomation,
|
||||
taskId: number,
|
||||
) => {
|
||||
if (runningTaskAutomationId !== null) return;
|
||||
setRunningTaskAutomationId(automation.id);
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.runTaskAutomation(automation.id, taskId);
|
||||
if (board?.id) await loadBoard(board.id);
|
||||
await loadTaskAutomationButtons(taskId);
|
||||
if (activityOpen) await loadTaskActivity(taskId);
|
||||
} catch (automationError) {
|
||||
setError(getTaskError(automationError, "Не удалось выполнить автоматизацию"));
|
||||
} finally {
|
||||
setRunningTaskAutomationId(null);
|
||||
}
|
||||
}, [activityOpen, board?.id, loadBoard, loadTaskActivity, loadTaskAutomationButtons, runningTaskAutomationId]);
|
||||
|
||||
const clearTaskFormResources = useCallback(() => {
|
||||
setTaskFormFilesOpen(false);
|
||||
setTaskFormLinksOpen(false);
|
||||
@ -3998,6 +4067,7 @@ function TasksPageContent() {
|
||||
setTaskChecklistLoaded(false);
|
||||
setTaskChecklistDraft("");
|
||||
setCommentDraft("");
|
||||
setCommentFiles([]);
|
||||
setLinkedObjectsOpen(false);
|
||||
setCommentsOpen(false);
|
||||
setAttachmentsOpen(false);
|
||||
@ -4152,6 +4222,7 @@ function TasksPageContent() {
|
||||
setAttachmentsOpen(false);
|
||||
setChecklistOpen(false);
|
||||
setActivityOpen(false);
|
||||
setTaskDetailsOpen(false);
|
||||
setLinkedMessages([]);
|
||||
setLinkedEvents([]);
|
||||
setLinkedDocuments([]);
|
||||
@ -4169,9 +4240,13 @@ function TasksPageContent() {
|
||||
setTaskChecklistLoaded(false);
|
||||
setTaskChecklistDraft("");
|
||||
setTaskActivities([]);
|
||||
setTaskAutomationButtons([]);
|
||||
setTaskDetailsOpen(false);
|
||||
setCommentDraft("");
|
||||
setCommentFiles([]);
|
||||
setCardMenuTaskId(null);
|
||||
}, []);
|
||||
void loadTaskAutomationButtons(task.id);
|
||||
}, [loadTaskAutomationButtons]);
|
||||
|
||||
const toggleCardTaskMenu = useCallback((taskId: number) => {
|
||||
setCardMenuTaskId((current) => (current === taskId ? null : taskId));
|
||||
@ -4206,12 +4281,15 @@ function TasksPageContent() {
|
||||
setTaskChecklist([]);
|
||||
setTaskChecklistLoaded(false);
|
||||
setTaskActivities([]);
|
||||
setTaskAutomationButtons([]);
|
||||
setRunningTaskAutomationId(null);
|
||||
setLinkedObjectsOpen(false);
|
||||
setCommentsOpen(false);
|
||||
setAttachmentsOpen(false);
|
||||
setChecklistOpen(false);
|
||||
setActivityOpen(false);
|
||||
setCommentDraft("");
|
||||
setCommentFiles([]);
|
||||
setTaskChecklistDraft("");
|
||||
setParticipantModalOpen(false);
|
||||
setParticipantModalSearch("");
|
||||
@ -5609,25 +5687,48 @@ function TasksPageContent() {
|
||||
}, [refreshViewedTaskLinks, saving, viewTask]);
|
||||
|
||||
const addViewedTaskComment = useCallback(async () => {
|
||||
if (!board || !viewTask || saving || !commentDraft.trim()) return;
|
||||
const text = commentDraft.trim();
|
||||
if (!board || !viewTask || saving || (!text && commentFiles.length === 0)) return;
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await apiClient.addTaskComment(viewTask.id, commentDraft.trim());
|
||||
const created = await apiClient.addTaskComment(viewTask.id, text, commentFiles);
|
||||
setTaskComments((current) => [
|
||||
...current.filter((comment) => comment.id !== created.id),
|
||||
created,
|
||||
]);
|
||||
if (created.attachments?.length) {
|
||||
setTaskAttachments((current) => {
|
||||
const createdIds = new Set(created.attachments?.map((attachment) => attachment.id));
|
||||
return [
|
||||
...current.filter((attachment) => !createdIds.has(attachment.id)),
|
||||
...(created.attachments || []),
|
||||
];
|
||||
});
|
||||
}
|
||||
setCommentDraft("");
|
||||
setCommentFiles([]);
|
||||
await loadBoard(board.id);
|
||||
if (linkedObjectsOpen) await refreshViewedTaskLinks();
|
||||
if (activityOpen) await loadTaskActivity(viewTask.id);
|
||||
} catch (commentError) {
|
||||
setError(getTaskError(commentError, "Не удалось добавить комментарий"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [activityOpen, board, commentDraft, loadBoard, loadTaskActivity, saving, viewTask]);
|
||||
}, [
|
||||
activityOpen,
|
||||
board,
|
||||
commentDraft,
|
||||
commentFiles,
|
||||
linkedObjectsOpen,
|
||||
loadBoard,
|
||||
loadTaskActivity,
|
||||
refreshViewedTaskLinks,
|
||||
saving,
|
||||
viewTask,
|
||||
]);
|
||||
|
||||
const beginEditingViewedTaskComment = useCallback((comment: TaskComment) => {
|
||||
setEditingCommentId(comment.id);
|
||||
@ -5652,13 +5753,23 @@ function TasksPageContent() {
|
||||
)));
|
||||
setEditingCommentId(null);
|
||||
setEditingCommentDraft("");
|
||||
if (linkedObjectsOpen) await refreshViewedTaskLinks();
|
||||
if (activityOpen) await loadTaskActivity(viewTask.id);
|
||||
} catch (commentError) {
|
||||
setError(getTaskError(commentError, "Не удалось изменить комментарий"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [activityOpen, editingCommentDraft, editingCommentId, loadTaskActivity, saving, viewTask]);
|
||||
}, [
|
||||
activityOpen,
|
||||
editingCommentDraft,
|
||||
editingCommentId,
|
||||
linkedObjectsOpen,
|
||||
loadTaskActivity,
|
||||
refreshViewedTaskLinks,
|
||||
saving,
|
||||
viewTask,
|
||||
]);
|
||||
|
||||
const deleteViewedTaskComment = useCallback(async (commentId: number) => {
|
||||
if (!board || !viewTask || saving) return;
|
||||
@ -6208,6 +6319,19 @@ function TasksPageContent() {
|
||||
<Plus size={14} className="app-text-muted shrink-0" />
|
||||
<span>Добавить колонку</span>
|
||||
</button>
|
||||
{user?.auth?.is_staff || user?.auth?.is_superuser ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBoardActionsMenuOpen(false);
|
||||
setAutomationModalOpen(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Workflow size={14} className="app-text-muted shrink-0" />
|
||||
<span>Автоматизация</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => board && void setDefaultBoard(board)}
|
||||
@ -6574,6 +6698,18 @@ function TasksPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskAutomationManager
|
||||
isOpen={automationModalOpen}
|
||||
onClose={() => setAutomationModalOpen(false)}
|
||||
currentBoard={board}
|
||||
boards={boards}
|
||||
employees={employees}
|
||||
labels={taskLabels}
|
||||
onChanged={() => {
|
||||
if (viewTaskId) void loadTaskAutomationButtons(viewTaskId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={boardModalOpen}
|
||||
onClose={closeBoardModal}
|
||||
@ -7015,6 +7151,10 @@ function TasksPageContent() {
|
||||
onClose={closeTaskView}
|
||||
title="Задача"
|
||||
size="md"
|
||||
className={taskAddMenuOpen
|
||||
? "h-[min(34rem,95vh)] sm:h-[min(34rem,90vh)]"
|
||||
: ""
|
||||
}
|
||||
closeOnClickOutside
|
||||
>
|
||||
{viewTask ? (
|
||||
@ -7131,6 +7271,20 @@ function TasksPageContent() {
|
||||
<UserPlus size={15} className="app-text-muted" />
|
||||
Участники
|
||||
</button>
|
||||
{user?.auth?.is_staff || user?.auth?.is_superuser ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTaskAddMenuOpen(false);
|
||||
closeTaskView();
|
||||
setAutomationModalOpen(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Workflow size={15} className="app-text-muted" />
|
||||
Автоматизация
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@ -7166,6 +7320,28 @@ function TasksPageContent() {
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{taskAutomationButtonsLoading ? (
|
||||
<span className="app-pill inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Действия
|
||||
</span>
|
||||
) : taskAutomationButtons.map((automation) => (
|
||||
<button
|
||||
key={automation.id}
|
||||
type="button"
|
||||
onClick={() => void runTaskAutomation(automation, viewTask.id)}
|
||||
disabled={runningTaskAutomationId !== null}
|
||||
className="app-action-secondary inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium disabled:opacity-50"
|
||||
title={automation.description || automation.name}
|
||||
>
|
||||
{runningTaskAutomationId === automation.id ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={12} />
|
||||
)}
|
||||
<span className="max-w-40 truncate">{automation.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{viewTaskColumn ? (
|
||||
<div ref={viewColumnMenuRef} className="relative">
|
||||
<button
|
||||
@ -7823,11 +7999,21 @@ function TasksPageContent() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="app-text-wrap whitespace-pre-wrap text-sm leading-5 text-[var(--foreground)]">
|
||||
{comment.text}
|
||||
</p>
|
||||
)}
|
||||
) : comment.text ? (
|
||||
<TaskCommentText text={comment.text} />
|
||||
) : null}
|
||||
{comment.attachments?.length ? (
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
{comment.attachments.map((attachment) => (
|
||||
<TaskCommentAttachment
|
||||
key={attachment.id}
|
||||
taskId={viewTask.id}
|
||||
attachment={attachment}
|
||||
onDownload={downloadViewedTaskAttachment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -7841,7 +8027,10 @@ function TasksPageContent() {
|
||||
<CommentComposer
|
||||
value={commentDraft}
|
||||
onChange={setCommentDraft}
|
||||
files={commentFiles}
|
||||
onFilesChange={setCommentFiles}
|
||||
onSubmit={addViewedTaskComment}
|
||||
submitDisabled={saving || (!commentDraft.trim() && commentFiles.length === 0)}
|
||||
disabled={saving}
|
||||
multiline
|
||||
rows={2}
|
||||
@ -8044,43 +8233,63 @@ function TasksPageContent() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<p className="app-card-caption">Доска</p>
|
||||
<p className="mt-1 truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{board?.name || "Не указана"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<p className="app-card-caption">Создал</p>
|
||||
<p className="mt-1 truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{viewTask.created_by ? displayUserName(viewTask.created_by) : "Не указан"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<p className="app-card-caption">Дата создания</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground)]">
|
||||
{formatDate(viewTask.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<p className="app-card-caption">Колонка</p>
|
||||
<p className="mt-1 truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{viewTaskColumn?.name || viewTask.column_name || "Не указана"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<p className="app-card-caption">Дорожка</p>
|
||||
<p className="mt-1 truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{viewTaskRow?.name || "Без дорожки"}
|
||||
</p>
|
||||
</div>
|
||||
{viewTask.completed_at ? (
|
||||
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3 sm:col-span-2">
|
||||
<p className="app-card-caption">Завершено</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground)]">
|
||||
{formatDate(viewTask.completed_at)}
|
||||
</p>
|
||||
<div className="rounded-xl border border-[var(--border-subtle)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTaskDetailsOpen((current) => !current)}
|
||||
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left"
|
||||
aria-expanded={taskDetailsOpen}
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center gap-2 text-sm font-medium text-[var(--foreground)]">
|
||||
<FileText size={15} className="app-text-muted shrink-0" />
|
||||
Подробнее
|
||||
</span>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className={`app-text-muted shrink-0 transition-transform ${taskDetailsOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{taskDetailsOpen ? (
|
||||
<div className="grid gap-3 border-t border-[var(--border-subtle)] p-3 sm:grid-cols-2">
|
||||
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<p className="app-card-caption">Доска</p>
|
||||
<p className="mt-1 truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{board?.name || "Не указана"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<p className="app-card-caption">Создал</p>
|
||||
<p className="mt-1 truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{viewTask.created_by ? displayUserName(viewTask.created_by) : "Не указан"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<p className="app-card-caption">Дата создания</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground)]">
|
||||
{formatDate(viewTask.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<p className="app-card-caption">Колонка</p>
|
||||
<p className="mt-1 truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{viewTaskColumn?.name || viewTask.column_name || "Не указана"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<p className="app-card-caption">Дорожка</p>
|
||||
<p className="mt-1 truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{viewTaskRow?.name || "Без дорожки"}
|
||||
</p>
|
||||
</div>
|
||||
{viewTask.completed_at ? (
|
||||
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<p className="app-card-caption">Завершено</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground)]">
|
||||
{formatDate(viewTask.completed_at)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Send, Trash2 } from "lucide-react";
|
||||
import { Paperclip, Send, Trash2, X } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
|
||||
type CommentComposerProps = {
|
||||
disabled?: boolean;
|
||||
files?: File[];
|
||||
multiline?: boolean;
|
||||
onCancel?: () => void;
|
||||
onChange: (value: string) => void;
|
||||
onFilesChange?: (files: File[]) => void;
|
||||
onSubmit: () => void | Promise<void>;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
@ -16,29 +19,99 @@ type CommentComposerProps = {
|
||||
|
||||
export function CommentComposer({
|
||||
disabled = false,
|
||||
files = [],
|
||||
multiline = false,
|
||||
onCancel,
|
||||
onChange,
|
||||
onFilesChange,
|
||||
onSubmit,
|
||||
placeholder = "Добавить комментарий",
|
||||
rows = 2,
|
||||
submitDisabled,
|
||||
value,
|
||||
}: CommentComposerProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const resolvedSubmitDisabled =
|
||||
submitDisabled ?? (disabled || !value.trim());
|
||||
submitDisabled ?? (disabled || (!value.trim() && files.length === 0));
|
||||
|
||||
const appendFiles = (selectedFiles: File[]) => {
|
||||
if (!onFilesChange || selectedFiles.length === 0) return;
|
||||
const existing = new Set(
|
||||
files.map((file) => `${file.name}:${file.size}:${file.lastModified}`),
|
||||
);
|
||||
onFilesChange([
|
||||
...files,
|
||||
...selectedFiles.filter((file) => {
|
||||
const identity = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
if (existing.has(identity)) return false;
|
||||
existing.add(identity);
|
||||
return true;
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
if (multiline) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{onFilesChange ? (
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="sr-only"
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
appendFiles(Array.from(event.currentTarget.files || []));
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="app-input w-full rounded-lg p-3 text-sm resize-none"
|
||||
rows={rows}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{files.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{files.map((file, index) => (
|
||||
<span
|
||||
key={`${file.name}:${file.size}:${file.lastModified}:${index}`}
|
||||
className="app-pill inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-full py-1 pl-2.5 pr-1 text-[11px]"
|
||||
title={file.name}
|
||||
>
|
||||
<Paperclip size={11} className="shrink-0" />
|
||||
<span className="max-w-48 truncate">{file.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFilesChange?.(files.filter((_, itemIndex) => itemIndex !== index))}
|
||||
disabled={disabled}
|
||||
className="app-icon-button flex h-5 w-5 shrink-0 items-center justify-center rounded-full"
|
||||
title={`Убрать ${file.name}`}
|
||||
aria-label={`Убрать файл ${file.name}`}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{onFilesChange ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
className="app-icon-button inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg disabled:opacity-50"
|
||||
title="Добавить файл"
|
||||
aria-label="Добавить файл к комментарию"
|
||||
>
|
||||
<Paperclip size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1" />
|
||||
{onCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
644
frontend/src/components/tasks/TaskAutomationManager.tsx
Normal file
644
frontend/src/components/tasks/TaskAutomationManager.tsx
Normal file
@ -0,0 +1,644 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CalendarClock,
|
||||
Check,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
Power,
|
||||
Trash2,
|
||||
Workflow,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Modal } from "@/components/ui";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { displayUserName } from "@/lib/shared";
|
||||
import type {
|
||||
TaskAutomation,
|
||||
TaskAutomationAction,
|
||||
TaskAutomationCatalog,
|
||||
TaskAutomationCondition,
|
||||
TaskAutomationKind,
|
||||
TaskAutomationTrigger,
|
||||
TaskBoard,
|
||||
TaskBoardSummary,
|
||||
TaskLabel,
|
||||
TaskPriority,
|
||||
User,
|
||||
} from "@/types/api";
|
||||
|
||||
type ConditionDraft = {
|
||||
id: string;
|
||||
field: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type ConditionGroupDraft = {
|
||||
id: string;
|
||||
conditions: ConditionDraft[];
|
||||
};
|
||||
|
||||
type ActionDraft = TaskAutomationAction & { id: string };
|
||||
|
||||
type AutomationForm = {
|
||||
id: number | null;
|
||||
name: string;
|
||||
description: string;
|
||||
kind: TaskAutomationKind;
|
||||
trigger: TaskAutomationTrigger;
|
||||
groups: ConditionGroupDraft[];
|
||||
actions: ActionDraft[];
|
||||
appliesToAllBoards: boolean;
|
||||
boardIds: number[];
|
||||
isActive: boolean;
|
||||
stopOnError: boolean;
|
||||
scheduleMode: "task_due_date" | "fixed_datetime";
|
||||
daysBefore: string;
|
||||
scheduleTime: string;
|
||||
fixedAt: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentBoard: TaskBoard | null;
|
||||
boards: TaskBoardSummary[];
|
||||
employees: User[];
|
||||
labels: TaskLabel[];
|
||||
onChanged?: () => void;
|
||||
};
|
||||
|
||||
const NO_VALUE_OPERATORS = new Set(["is_empty", "not_empty", "is_today", "is_past", "is_future"]);
|
||||
const BOOLEAN_FIELDS = new Set(["completed", "has_attachments", "checklist_complete"]);
|
||||
const USER_FIELDS = new Set(["assignee_id", "creator_id", "participant_ids"]);
|
||||
const NUMERIC_FIELDS = new Set(["board_id", "column_id", "assignee_id", "creator_id", "participant_ids", "label_ids"]);
|
||||
|
||||
let draftSequence = 0;
|
||||
function draftId(prefix: string) {
|
||||
draftSequence += 1;
|
||||
return `${prefix}-${draftSequence}`;
|
||||
}
|
||||
|
||||
function emptyCondition(): ConditionDraft {
|
||||
return { id: draftId("condition"), field: "column_id", operator: "eq", value: "" };
|
||||
}
|
||||
|
||||
function emptyGroup(): ConditionGroupDraft {
|
||||
return { id: draftId("group"), conditions: [emptyCondition()] };
|
||||
}
|
||||
|
||||
function emptyAction(): ActionDraft {
|
||||
return { id: draftId("action"), type: "move", target: "final" };
|
||||
}
|
||||
|
||||
function emptyForm(boardId?: number | null): AutomationForm {
|
||||
return {
|
||||
id: null,
|
||||
name: "",
|
||||
description: "",
|
||||
kind: "event",
|
||||
trigger: "task_created",
|
||||
groups: [emptyGroup()],
|
||||
actions: [emptyAction()],
|
||||
appliesToAllBoards: false,
|
||||
boardIds: boardId ? [boardId] : [],
|
||||
isActive: true,
|
||||
stopOnError: true,
|
||||
scheduleMode: "task_due_date",
|
||||
daysBefore: "0",
|
||||
scheduleTime: "09:00",
|
||||
fixedAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
function isConditionGroup(
|
||||
condition: TaskAutomationCondition,
|
||||
): condition is Extract<TaskAutomationCondition, { children: TaskAutomationCondition[] }> {
|
||||
return "children" in condition;
|
||||
}
|
||||
|
||||
function conditionToDraft(condition: TaskAutomationCondition): ConditionDraft | null {
|
||||
if (isConditionGroup(condition)) return null;
|
||||
const rawValue = condition.value;
|
||||
return {
|
||||
id: draftId("condition"),
|
||||
field: condition.field,
|
||||
operator: condition.operator,
|
||||
value: Array.isArray(rawValue) ? rawValue.join(",") : String(rawValue ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function groupsFromCondition(root: TaskAutomationCondition): ConditionGroupDraft[] {
|
||||
if (!isConditionGroup(root)) {
|
||||
const condition = conditionToDraft(root);
|
||||
return condition ? [{ id: draftId("group"), conditions: [condition] }] : [emptyGroup()];
|
||||
}
|
||||
const sourceGroups = root.operator === "or" ? root.children : [root];
|
||||
const groups = sourceGroups.map((group) => {
|
||||
const nodes = isConditionGroup(group) && group.operator === "and" ? group.children : [group];
|
||||
return {
|
||||
id: draftId("group"),
|
||||
conditions: nodes.map(conditionToDraft).filter(Boolean) as ConditionDraft[],
|
||||
};
|
||||
}).filter((group) => group.conditions.length > 0);
|
||||
return groups.length ? groups : [emptyGroup()];
|
||||
}
|
||||
|
||||
function toLocalDateTime(value?: string) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
|
||||
const offset = date.getTimezoneOffset() * 60_000;
|
||||
return new Date(date.getTime() - offset).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function formFromAutomation(automation: TaskAutomation): AutomationForm {
|
||||
return {
|
||||
id: automation.id,
|
||||
name: automation.name,
|
||||
description: automation.description || "",
|
||||
kind: automation.kind,
|
||||
trigger: automation.trigger,
|
||||
groups: groupsFromCondition(automation.conditions),
|
||||
actions: automation.actions.map((action) => ({ ...action, id: draftId("action") })),
|
||||
appliesToAllBoards: automation.applies_to_all_boards,
|
||||
boardIds: automation.boards || [],
|
||||
isActive: automation.is_active,
|
||||
stopOnError: automation.stop_on_error,
|
||||
scheduleMode: automation.schedule_config?.mode || "task_due_date",
|
||||
daysBefore: String(automation.schedule_config?.days_before ?? 0),
|
||||
scheduleTime: automation.schedule_config?.time || "09:00",
|
||||
fixedAt: toLocalDateTime(automation.schedule_config?.at),
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
const raw = String((error as Error)?.message || fallback);
|
||||
const start = raw.indexOf("{");
|
||||
if (start < 0) return raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw.slice(start)) as Record<string, unknown>;
|
||||
const value = parsed.detail || parsed.error || Object.values(parsed)[0];
|
||||
if (Array.isArray(value)) return String(value[0] || fallback);
|
||||
if (typeof value === "string") return value;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export default function TaskAutomationManager({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentBoard,
|
||||
boards,
|
||||
employees,
|
||||
labels,
|
||||
onChanged,
|
||||
}: Props) {
|
||||
const [catalog, setCatalog] = useState<TaskAutomationCatalog | null>(null);
|
||||
const [automations, setAutomations] = useState<TaskAutomation[]>([]);
|
||||
const [activeKind, setActiveKind] = useState<TaskAutomationKind>("event");
|
||||
const [form, setForm] = useState<AutomationForm>(() => emptyForm(currentBoard?.id));
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [catalogResponse, rulesResponse] = await Promise.all([
|
||||
apiClient.getTaskAutomationCatalog(),
|
||||
apiClient.getTaskAutomations({ ordering: "position,id" }),
|
||||
]);
|
||||
setCatalog(catalogResponse);
|
||||
setAutomations(Array.isArray(rulesResponse) ? rulesResponse : rulesResponse.results || []);
|
||||
} catch (loadError) {
|
||||
setError(errorMessage(loadError, "Не удалось загрузить автоматизации"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
void load();
|
||||
}, [isOpen, load]);
|
||||
|
||||
const visibleAutomations = useMemo(
|
||||
() => automations.filter((automation) => automation.kind === activeKind),
|
||||
[activeKind, automations],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => currentBoard?.columns?.filter((column) => !column.is_archived) || [],
|
||||
[currentBoard?.columns],
|
||||
);
|
||||
|
||||
const openCreate = (kind: TaskAutomationKind = activeKind) => {
|
||||
const next = emptyForm(currentBoard?.id);
|
||||
next.kind = kind;
|
||||
next.trigger = kind === "button" ? "manual" : kind === "schedule" ? "date_reached" : "task_created";
|
||||
setForm(next);
|
||||
setEditorOpen(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const changeFormKind = (kind: TaskAutomationKind) => {
|
||||
setActiveKind(kind);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
kind,
|
||||
trigger: kind === "button"
|
||||
? "manual"
|
||||
: kind === "schedule"
|
||||
? "date_reached"
|
||||
: current.trigger === "manual" || current.trigger === "date_reached"
|
||||
? "task_created"
|
||||
: current.trigger,
|
||||
}));
|
||||
};
|
||||
|
||||
const openEdit = (automation: TaskAutomation) => {
|
||||
setForm(formFromAutomation(automation));
|
||||
setEditorOpen(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const updateCondition = (
|
||||
groupId: string,
|
||||
conditionId: string,
|
||||
patch: Partial<ConditionDraft>,
|
||||
) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
groups: current.groups.map((group) => group.id === groupId ? {
|
||||
...group,
|
||||
conditions: group.conditions.map((condition) => (
|
||||
condition.id === conditionId ? { ...condition, ...patch } : condition
|
||||
)),
|
||||
} : group),
|
||||
}));
|
||||
};
|
||||
|
||||
const removeCondition = (groupId: string, conditionId: string) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
groups: current.groups.map((group) => group.id === groupId ? {
|
||||
...group,
|
||||
conditions: group.conditions.filter((condition) => condition.id !== conditionId),
|
||||
} : group).filter((group) => group.conditions.length > 0),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateAction = (id: string, patch: Partial<ActionDraft>) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
actions: current.actions.map((action) => action.id === id ? { ...action, ...patch } : action),
|
||||
}));
|
||||
};
|
||||
|
||||
const conditionValue = (condition: ConditionDraft): unknown => {
|
||||
if (NO_VALUE_OPERATORS.has(condition.operator)) return null;
|
||||
if (BOOLEAN_FIELDS.has(condition.field)) return condition.value === "true";
|
||||
if (condition.operator === "in" || condition.operator === "not_in") {
|
||||
return condition.value.split(",").map((value) => value.trim()).filter(Boolean).map((value) => (
|
||||
NUMERIC_FIELDS.has(condition.field) ? Number(value) : value
|
||||
));
|
||||
}
|
||||
return NUMERIC_FIELDS.has(condition.field) && condition.value !== ""
|
||||
? Number(condition.value)
|
||||
: condition.value;
|
||||
};
|
||||
|
||||
const buildConditions = (): TaskAutomationCondition => {
|
||||
const groups = form.groups.map((group) => ({
|
||||
operator: "and" as const,
|
||||
children: group.conditions.map((condition) => ({
|
||||
field: condition.field,
|
||||
operator: condition.operator,
|
||||
value: conditionValue(condition),
|
||||
})),
|
||||
})).filter((group) => group.children.length > 0);
|
||||
if (groups.length === 0) return { operator: "and", children: [] };
|
||||
return groups.length === 1 ? groups[0] : { operator: "or", children: groups };
|
||||
};
|
||||
|
||||
const buildActions = () => form.actions.map((action) => {
|
||||
const payload: Partial<ActionDraft> = { ...action };
|
||||
delete payload.id;
|
||||
if (payload.type === "add_checklist" && typeof payload.text === "string") {
|
||||
payload.items = payload.text.split("\n").map((item) => item.trim()).filter(Boolean);
|
||||
delete payload.text;
|
||||
}
|
||||
return payload as TaskAutomationAction;
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
if (!form.name.trim() || form.actions.length === 0) return;
|
||||
if (!form.appliesToAllBoards && form.boardIds.length === 0) {
|
||||
setError("Выберите хотя бы одну доску или включите применение ко всем доскам.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
kind: form.kind,
|
||||
trigger: form.kind === "button" ? "manual" : form.kind === "schedule" ? "date_reached" : form.trigger,
|
||||
conditions: buildConditions(),
|
||||
actions: buildActions(),
|
||||
schedule_config: form.kind === "schedule" ? (
|
||||
form.scheduleMode === "task_due_date"
|
||||
? { mode: "task_due_date" as const, days_before: Number(form.daysBefore || 0), time: form.scheduleTime }
|
||||
: { mode: "fixed_datetime" as const, at: form.fixedAt ? new Date(form.fixedAt).toISOString() : "" }
|
||||
) : {},
|
||||
applies_to_all_boards: form.appliesToAllBoards,
|
||||
boards: form.appliesToAllBoards ? [] : form.boardIds,
|
||||
is_active: form.isActive,
|
||||
stop_on_error: form.stopOnError,
|
||||
position: form.id
|
||||
? automations.find((automation) => automation.id === form.id)?.position || 0
|
||||
: automations.length,
|
||||
};
|
||||
if (form.id) {
|
||||
await apiClient.updateTaskAutomation(form.id, payload);
|
||||
} else {
|
||||
await apiClient.createTaskAutomation(payload as Parameters<typeof apiClient.createTaskAutomation>[0]);
|
||||
}
|
||||
setEditorOpen(false);
|
||||
await load();
|
||||
onChanged?.();
|
||||
} catch (saveError) {
|
||||
setError(errorMessage(saveError, "Не удалось сохранить автоматизацию"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (automation: TaskAutomation) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.updateTaskAutomation(automation.id, { is_active: !automation.is_active });
|
||||
await load();
|
||||
} catch (toggleError) {
|
||||
setError(errorMessage(toggleError, "Не удалось изменить состояние автоматизации"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (automation: TaskAutomation) => {
|
||||
if (!window.confirm(`Удалить автоматизацию «${automation.name}»?`)) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.deleteTaskAutomation(automation.id);
|
||||
await load();
|
||||
onChanged?.();
|
||||
} catch (deleteError) {
|
||||
setError(errorMessage(deleteError, "Не удалось удалить автоматизацию"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderConditionValue = (group: ConditionGroupDraft, condition: ConditionDraft) => {
|
||||
if (NO_VALUE_OPERATORS.has(condition.operator)) return null;
|
||||
const commonProps = {
|
||||
value: condition.value,
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => (
|
||||
updateCondition(group.id, condition.id, { value: event.target.value })
|
||||
),
|
||||
className: "app-input min-w-0 rounded-lg px-2.5 py-2 text-xs",
|
||||
};
|
||||
if (BOOLEAN_FIELDS.has(condition.field)) {
|
||||
return <select {...commonProps}><option value="true">Да</option><option value="false">Нет</option></select>;
|
||||
}
|
||||
if (condition.field === "board_id") {
|
||||
return <select {...commonProps}><option value="">Выберите доску</option>{boards.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select>;
|
||||
}
|
||||
if (condition.field === "column_id") {
|
||||
return <select {...commonProps}><option value="">Выберите колонку</option>{columns.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select>;
|
||||
}
|
||||
if (USER_FIELDS.has(condition.field)) {
|
||||
return <select {...commonProps}><option value="">Выберите сотрудника</option>{employees.map((item) => <option key={item.id} value={item.id}>{displayUserName(item)}</option>)}</select>;
|
||||
}
|
||||
if (condition.field === "label_ids") {
|
||||
return <select {...commonProps}><option value="">Выберите метку</option>{labels.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select>;
|
||||
}
|
||||
if (condition.field === "priority") {
|
||||
return <select {...commonProps}>{Object.entries(catalog?.priorities || {}).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select>;
|
||||
}
|
||||
return <input {...commonProps} type={condition.field === "due_date" ? "date" : "text"} placeholder={condition.operator === "in" || condition.operator === "not_in" ? "Значения через запятую" : "Значение"} />;
|
||||
};
|
||||
|
||||
const renderActionOptions = (action: ActionDraft) => {
|
||||
const inputClass = "app-input min-w-0 rounded-lg px-2.5 py-2 text-xs";
|
||||
if (action.type === "move") {
|
||||
return (
|
||||
<div className="grid min-w-0 gap-2 sm:grid-cols-2">
|
||||
<select value={action.target || "final"} onChange={(event) => updateAction(action.id, { target: event.target.value as TaskAutomationAction["target"] })} className={inputClass}>
|
||||
<option value="final">В финальную колонку</option>
|
||||
<option value="first">В первую колонку</option>
|
||||
<option value="column">В выбранную колонку</option>
|
||||
<option value="column_name">В колонку по названию</option>
|
||||
</select>
|
||||
{action.target === "column" ? (
|
||||
<select value={action.column_id || ""} onChange={(event) => updateAction(action.id, { column_id: Number(event.target.value) })} className={inputClass}>
|
||||
<option value="">Выберите колонку</option>
|
||||
{columns.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
) : action.target === "column_name" ? (
|
||||
<input value={action.column_name || ""} onChange={(event) => updateAction(action.id, { column_name: event.target.value })} className={inputClass} placeholder="Название колонки" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (["set_assignee", "add_participant", "remove_participant"].includes(action.type)) {
|
||||
return (
|
||||
<select value={action.user_id ?? ""} onChange={(event) => updateAction(action.id, { user_id: event.target.value ? Number(event.target.value) : null })} className={inputClass}>
|
||||
<option value="">{action.type === "set_assignee" ? "Снять исполнителя" : "Выберите сотрудника"}</option>
|
||||
{employees.map((item) => <option key={item.id} value={item.id}>{displayUserName(item)}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
if (["add_label", "remove_label"].includes(action.type)) {
|
||||
return <select value={action.label_id || ""} onChange={(event) => updateAction(action.id, { label_id: Number(event.target.value) })} className={inputClass}><option value="">Выберите метку</option>{labels.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select>;
|
||||
}
|
||||
if (action.type === "set_priority") {
|
||||
return <select value={action.priority || "normal"} onChange={(event) => updateAction(action.id, { priority: event.target.value as TaskPriority })} className={inputClass}>{Object.entries(catalog?.priorities || {}).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select>;
|
||||
}
|
||||
if (action.type === "set_due_date") {
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<select value={action.mode || "clear"} onChange={(event) => updateAction(action.id, { mode: event.target.value as TaskAutomationAction["mode"] })} className={inputClass}>
|
||||
<option value="clear">Убрать срок</option><option value="fixed">Установить дату</option><option value="relative">Сместить от текущей даты</option>
|
||||
</select>
|
||||
{action.mode === "fixed" ? <input type="date" value={action.date || ""} onChange={(event) => updateAction(action.id, { date: event.target.value })} className={inputClass} /> : null}
|
||||
{action.mode === "relative" ? <input type="number" value={action.days ?? 0} onChange={(event) => updateAction(action.id, { days: Number(event.target.value) })} className={inputClass} aria-label="Смещение в днях" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (action.type === "add_comment") {
|
||||
return <textarea value={action.text || ""} onChange={(event) => updateAction(action.id, { text: event.target.value })} className={`${inputClass} w-full`} rows={2} placeholder="Текст системного комментария" />;
|
||||
}
|
||||
if (action.type === "add_checklist") {
|
||||
return <textarea value={action.text || action.items?.join("\n") || ""} onChange={(event) => updateAction(action.id, { text: event.target.value })} className={`${inputClass} w-full`} rows={3} placeholder="Один пункт на строку" />;
|
||||
}
|
||||
return <p className="app-text-muted text-xs">Дополнительные параметры не требуются.</p>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Автоматизация задач" size="xl" closeOnClickOutside>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--border-subtle)] pb-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{([
|
||||
["event", "События", Workflow],
|
||||
["schedule", "Сроки", CalendarClock],
|
||||
["button", "Кнопки", Play],
|
||||
] as const).map(([kind, label, Icon]) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveKind(kind);
|
||||
if (editorOpen && form.id === null) changeFormKind(kind);
|
||||
}}
|
||||
className={`app-pill inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium ${activeKind === kind ? "app-selected ring-1 ring-[var(--accent-primary)]" : ""}`}
|
||||
aria-pressed={activeKind === kind}
|
||||
>
|
||||
<Icon size={14} />{label}<span className="app-badge rounded-full px-1.5 py-0.5 text-[10px]">{automations.filter((item) => item.kind === kind).length}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" onClick={() => openCreate()} className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"><Plus size={15} />Создать</button>
|
||||
</div>
|
||||
|
||||
{error ? <div className="app-feedback-danger rounded-xl px-3 py-2 text-sm">{error}</div> : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="py-12 text-center"><Loader2 size={24} className="mx-auto animate-spin text-sky-500" /></div>
|
||||
) : editorOpen ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div><h3 className="text-base font-semibold text-[var(--foreground)]">{form.id ? "Редактирование" : "Новая автоматизация"}</h3><p className="app-text-muted text-xs">Правила общие для системы, область досок задаётся отдельно.</p></div>
|
||||
<button type="button" onClick={() => setEditorOpen(false)} className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg" title="Закрыть редактор"><X size={15} /></button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<span className="app-text-muted mb-1.5 block text-xs">Тип автоматизации</span>
|
||||
<div className="grid grid-cols-3 gap-2 rounded-xl border border-[var(--border-subtle)] p-1">
|
||||
{([
|
||||
["event", "Событие", Workflow],
|
||||
["schedule", "Срок", CalendarClock],
|
||||
["button", "Кнопка", Play],
|
||||
] as const).map(([kind, label, Icon]) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
onClick={() => changeFormKind(kind)}
|
||||
className={`inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-2 text-sm font-medium transition ${
|
||||
form.kind === kind
|
||||
? "app-selected text-[var(--accent-primary-strong)]"
|
||||
: "app-action-ghost"
|
||||
}`}
|
||||
aria-pressed={form.kind === kind}
|
||||
>
|
||||
<Icon size={15} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="sm:col-span-2"><span className="app-text-muted mb-1 block text-xs">Название</span><input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} className="app-input w-full rounded-xl px-3 py-2 text-sm" autoFocus /></label>
|
||||
<label className="sm:col-span-2"><span className="app-text-muted mb-1 block text-xs">Описание</span><textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} className="app-input w-full rounded-xl px-3 py-2 text-sm" rows={2} /></label>
|
||||
{form.kind === "event" ? <label><span className="app-text-muted mb-1 block text-xs">Когда</span><select value={form.trigger} onChange={(event) => setForm((current) => ({ ...current, trigger: event.target.value as TaskAutomationTrigger }))} className="app-input w-full rounded-xl px-3 py-2 text-sm">{Object.entries(catalog?.event_triggers || {}).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label> : null}
|
||||
{form.kind === "schedule" ? (
|
||||
<div className="grid gap-2 sm:col-span-2 sm:grid-cols-3">
|
||||
<label><span className="app-text-muted mb-1 block text-xs">Тип срока</span><select value={form.scheduleMode} onChange={(event) => setForm((current) => ({ ...current, scheduleMode: event.target.value as AutomationForm["scheduleMode"] }))} className="app-input w-full rounded-xl px-3 py-2 text-sm"><option value="task_due_date">От срока задачи</option><option value="fixed_datetime">Определённая дата</option></select></label>
|
||||
{form.scheduleMode === "task_due_date" ? <><label><span className="app-text-muted mb-1 block text-xs">За сколько дней</span><input type="number" min="0" value={form.daysBefore} onChange={(event) => setForm((current) => ({ ...current, daysBefore: event.target.value }))} className="app-input w-full rounded-xl px-3 py-2 text-sm" /></label><label><span className="app-text-muted mb-1 block text-xs">Время</span><input type="time" value={form.scheduleTime} onChange={(event) => setForm((current) => ({ ...current, scheduleTime: event.target.value }))} className="app-input w-full rounded-xl px-3 py-2 text-sm" /></label></> : <label className="sm:col-span-2"><span className="app-text-muted mb-1 block text-xs">Дата и время</span><input type="datetime-local" value={form.fixedAt} onChange={(event) => setForm((current) => ({ ...current, fixedAt: event.target.value }))} className="app-input w-full rounded-xl px-3 py-2 text-sm" /></label>}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3"><div><h4 className="text-sm font-semibold">Область применения</h4><p className="app-text-muted text-xs">По умолчанию используется открытая доска.</p></div><label className="inline-flex items-center gap-2 text-xs"><input type="checkbox" checked={form.appliesToAllBoards} onChange={(event) => setForm((current) => ({ ...current, appliesToAllBoards: event.target.checked }))} className="accent-sky-500" />Все доски</label></div>
|
||||
{!form.appliesToAllBoards ? <div className="flex max-h-28 flex-wrap gap-2 overflow-y-auto">{boards.map((item) => { const selected = form.boardIds.includes(item.id); return <button key={item.id} type="button" onClick={() => setForm((current) => ({ ...current, boardIds: selected ? current.boardIds.filter((id) => id !== item.id) : [...current.boardIds, item.id] }))} className={`app-pill inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs ${selected ? "app-selected" : ""}`}>{selected ? <Check size={12} /> : null}{item.name}</button>; })}</div> : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<div><h4 className="text-sm font-semibold">Если</h4><p className="app-text-muted text-xs">Условия внутри блока объединяются через И, блоки между собой через ИЛИ. Пустые условия означают запуск для любой задачи.</p></div>
|
||||
{form.groups.map((group, groupIndex) => (
|
||||
<div key={group.id} className="space-y-2">
|
||||
{groupIndex > 0 ? <div className="flex items-center gap-2"><span className="h-px flex-1 bg-[var(--border-subtle)]" /><span className="app-badge rounded-full px-2 py-0.5 text-[10px] font-semibold">ИЛИ</span><span className="h-px flex-1 bg-[var(--border-subtle)]" /></div> : null}
|
||||
{group.conditions.map((condition, conditionIndex) => (
|
||||
<div key={condition.id} className="app-surface-muted grid gap-2 rounded-xl border border-[var(--border-subtle)] p-2 sm:grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.2fr)_auto] sm:items-center">
|
||||
<span className="app-text-muted text-[10px] font-semibold">{conditionIndex > 0 ? "И" : "ЕСЛИ"}</span>
|
||||
<select value={condition.field} onChange={(event) => updateCondition(group.id, condition.id, { field: event.target.value, value: "" })} className="app-input min-w-0 rounded-lg px-2.5 py-2 text-xs">{Object.entries(catalog?.condition_fields || {}).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select>
|
||||
<select value={condition.operator} onChange={(event) => updateCondition(group.id, condition.id, { operator: event.target.value })} className="app-input min-w-0 rounded-lg px-2.5 py-2 text-xs">{Object.entries(catalog?.condition_operators || {}).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select>
|
||||
{renderConditionValue(group, condition) || <span />}
|
||||
<button type="button" onClick={() => removeCondition(group.id, condition.id)} className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg text-[var(--danger-foreground)]" title="Удалить условие"><Trash2 size={13} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setForm((current) => ({ ...current, groups: current.groups.map((item) => item.id === group.id ? { ...item, conditions: [...item.conditions, emptyCondition()] } : item) }))} className="app-action-ghost inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs"><Plus size={13} />Условие И</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setForm((current) => ({ ...current, groups: [...current.groups, emptyGroup()] }))} className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs"><Plus size={13} />Группа ИЛИ</button>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<div><h4 className="text-sm font-semibold">То</h4><p className="app-text-muted text-xs">Действия выполняются сверху вниз.</p></div>
|
||||
{form.actions.map((action, index) => (
|
||||
<div key={action.id} className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<GripVertical size={15} className="app-text-muted mt-2 shrink-0" />
|
||||
<span className="app-badge mt-1.5 rounded-full px-2 py-0.5 text-[10px]">{index + 1}</span>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<select value={action.type} onChange={(event) => updateAction(action.id, { type: event.target.value })} className="app-input w-full rounded-lg px-2.5 py-2 text-xs">{Object.entries(catalog?.action_types || {}).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select>
|
||||
{renderActionOptions(action)}
|
||||
</div>
|
||||
<button type="button" onClick={() => setForm((current) => ({ ...current, actions: current.actions.filter((item) => item.id !== action.id) }))} className="app-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[var(--danger-foreground)]" title="Удалить действие"><Trash2 size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setForm((current) => ({ ...current, actions: [...current.actions, emptyAction()] }))} className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs"><Plus size={13} />Добавить действие</button>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--border-subtle)] pt-3">
|
||||
<div className="flex flex-wrap gap-4"><label className="inline-flex items-center gap-2 text-xs"><input type="checkbox" checked={form.isActive} onChange={(event) => setForm((current) => ({ ...current, isActive: event.target.checked }))} className="accent-sky-500" />Активна</label><label className="inline-flex items-center gap-2 text-xs"><input type="checkbox" checked={form.stopOnError} onChange={(event) => setForm((current) => ({ ...current, stopOnError: event.target.checked }))} className="accent-sky-500" />Остановить при ошибке</label></div>
|
||||
<div className="flex gap-2"><button type="button" onClick={() => setEditorOpen(false)} className="app-action-secondary rounded-lg px-3 py-2 text-sm">Отмена</button><button type="button" onClick={() => void save()} disabled={saving || !form.name.trim() || form.actions.length === 0} className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50">{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}Сохранить</button></div>
|
||||
</div>
|
||||
</div>
|
||||
) : visibleAutomations.length ? (
|
||||
<div className="space-y-2">
|
||||
{visibleAutomations.map((automation) => (
|
||||
<article key={automation.id} className="app-surface-muted flex items-center gap-3 rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<span className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${automation.is_active ? "app-selected" : "app-badge"}`}>{automation.kind === "schedule" ? <CalendarClock size={16} /> : automation.kind === "button" ? <Play size={16} /> : <Workflow size={16} />}</span>
|
||||
<div className="min-w-0 flex-1"><h3 className="truncate text-sm font-semibold text-[var(--foreground)]">{automation.name}</h3><p className="app-text-muted mt-0.5 truncate text-xs">{automation.applies_to_all_boards ? "Все доски" : `${automation.boards.length} досок`} · {automation.actions.length} действий{automation.last_run_at ? ` · запускалась ${new Date(automation.last_run_at).toLocaleString("ru-RU")}` : ""}</p></div>
|
||||
<button type="button" onClick={() => void toggleActive(automation)} disabled={saving} className={`app-icon-button flex h-8 w-8 items-center justify-center rounded-lg ${automation.is_active ? "text-emerald-500" : "app-text-muted"}`} title={automation.is_active ? "Отключить" : "Включить"}><Power size={14} /></button>
|
||||
<button type="button" onClick={() => openEdit(automation)} className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg" title="Редактировать"><Pencil size={14} /></button>
|
||||
<button type="button" onClick={() => void remove(automation)} disabled={saving} className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg text-[var(--danger-foreground)]" title="Удалить"><Trash2 size={14} /></button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl border border-dashed border-[var(--border-subtle)] px-4 py-10 text-center"><Workflow size={24} className="app-text-muted mx-auto mb-2" /><p className="text-sm font-medium">Автоматизаций пока нет</p><p className="app-text-muted mt-1 text-xs">Создайте правило для событий, сроков или ручную кнопку.</p></div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
127
frontend/src/components/tasks/TaskCommentAttachment.tsx
Normal file
127
frontend/src/components/tasks/TaskCommentAttachment.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Download, FileText, Loader2, Play } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { TaskAttachment } from "@/types/api";
|
||||
|
||||
function isImage(attachment: TaskAttachment): boolean {
|
||||
if (attachment.mime_type?.toLowerCase().startsWith("image/")) return true;
|
||||
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(attachment.file_name);
|
||||
}
|
||||
|
||||
function isVideo(attachment: TaskAttachment): boolean {
|
||||
if (attachment.mime_type?.toLowerCase().startsWith("video/")) return true;
|
||||
return /\.(m4v|mkv|mov|mp4|mpeg|mpg|ogv|webm)$/i.test(attachment.file_name);
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} Б`;
|
||||
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)} КБ`;
|
||||
return `${(size / (1024 * 1024)).toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})} МБ`;
|
||||
}
|
||||
|
||||
export default function TaskCommentAttachment({
|
||||
taskId,
|
||||
attachment,
|
||||
onDownload,
|
||||
}: {
|
||||
taskId: number;
|
||||
attachment: TaskAttachment;
|
||||
onDownload: (attachment: TaskAttachment) => void | Promise<void>;
|
||||
}) {
|
||||
const image = isImage(attachment);
|
||||
const video = isVideo(attachment);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!image && !video) return;
|
||||
|
||||
let active = true;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
void apiClient
|
||||
.downloadTaskAttachment(taskId, attachment)
|
||||
.then(({ blob }) => {
|
||||
if (!active) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setSource(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [attachment, image, taskId, video]);
|
||||
|
||||
if (image || video) {
|
||||
return (
|
||||
<div className="app-surface-elevated overflow-hidden rounded-lg border border-[var(--border-subtle)]">
|
||||
<div className="relative aspect-video min-h-32 bg-[var(--surface-secondary)]">
|
||||
{source && image ? (
|
||||
<Image
|
||||
src={source}
|
||||
alt={attachment.file_name}
|
||||
fill
|
||||
unoptimized
|
||||
sizes="(max-width: 640px) 100vw, 320px"
|
||||
className="object-contain"
|
||||
/>
|
||||
) : source && video ? (
|
||||
<video
|
||||
src={source}
|
||||
controls
|
||||
preload="metadata"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="app-text-muted absolute inset-0 flex items-center justify-center">
|
||||
{failed ? <Play size={20} /> : <Loader2 size={18} className="animate-spin" />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDownload(attachment)}
|
||||
className="flex w-full min-w-0 items-center gap-2 border-t border-[var(--border-subtle)] px-2.5 py-2 text-left"
|
||||
title={`Скачать ${attachment.file_name}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--foreground)]">
|
||||
{attachment.file_name}
|
||||
</span>
|
||||
<Download size={13} className="app-text-muted shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDownload(attachment)}
|
||||
className="app-surface-elevated flex min-w-0 items-center gap-2 rounded-lg border border-[var(--border-subtle)] px-2.5 py-2 text-left"
|
||||
title={`Скачать ${attachment.file_name}`}
|
||||
>
|
||||
<span className="app-selected flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<FileText size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium text-[var(--foreground)]">
|
||||
{attachment.file_name}
|
||||
</span>
|
||||
<span className="app-text-muted mt-0.5 block text-[10px]">
|
||||
{formatFileSize(attachment.file_size)}
|
||||
</span>
|
||||
</span>
|
||||
<Download size={13} className="app-text-muted shrink-0" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,9 @@ import type {
|
||||
TaskColumnRow,
|
||||
TaskActivity,
|
||||
TaskAttachment,
|
||||
TaskAutomation,
|
||||
TaskAutomationCatalog,
|
||||
TaskAutomationRun,
|
||||
TaskChecklistItem,
|
||||
TaskComment,
|
||||
TaskLabel,
|
||||
@ -127,6 +130,39 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
|
||||
request(`/api/v1/tasks/boards/${id}/notifications/read/`, {
|
||||
method: "POST",
|
||||
}),
|
||||
getTaskAutomationCatalog: (): Promise<TaskAutomationCatalog> =>
|
||||
request("/api/v1/tasks/automations/catalog/"),
|
||||
getTaskAutomations: (
|
||||
params?: Record<string, string | number>,
|
||||
): Promise<TaskAutomation[] | { results: TaskAutomation[] }> =>
|
||||
request(`/api/v1/tasks/automations/${buildQuery(params)}`),
|
||||
createTaskAutomation: (
|
||||
data: Omit<TaskAutomation, "id" | "created_by" | "last_run_at" | "runs_count" | "can_manage" | "created_at" | "updated_at">,
|
||||
): Promise<TaskAutomation> =>
|
||||
request("/api/v1/tasks/automations/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
updateTaskAutomation: (
|
||||
id: number,
|
||||
data: Partial<TaskAutomation>,
|
||||
): Promise<TaskAutomation> =>
|
||||
request(`/api/v1/tasks/automations/${id}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteTaskAutomation: (id: number): Promise<void> =>
|
||||
request(`/api/v1/tasks/automations/${id}/`, { method: "DELETE" }),
|
||||
getTaskAutomationButtons: (taskId: number): Promise<TaskAutomation[]> =>
|
||||
request(`/api/v1/tasks/automations/buttons/?task=${taskId}`),
|
||||
runTaskAutomation: (
|
||||
automationId: number,
|
||||
taskId: number,
|
||||
): Promise<TaskAutomationRun> =>
|
||||
request(`/api/v1/tasks/automations/${automationId}/run/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ task: taskId }),
|
||||
}),
|
||||
createTaskColumn: (data: Record<string, any>): Promise<TaskColumn> =>
|
||||
request("/api/v1/tasks/columns/", {
|
||||
method: "POST",
|
||||
@ -261,11 +297,19 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
|
||||
}),
|
||||
getTaskComments: (id: number): Promise<TaskComment[]> =>
|
||||
request(`/api/v1/tasks/${id}/comments/`),
|
||||
addTaskComment: (id: number, text: string): Promise<TaskComment> =>
|
||||
request(`/api/v1/tasks/${id}/comments/`, {
|
||||
addTaskComment: (
|
||||
id: number,
|
||||
text: string,
|
||||
files: File[] = [],
|
||||
): Promise<TaskComment> => {
|
||||
const formData = new FormData();
|
||||
formData.append("text", text);
|
||||
files.forEach((file) => formData.append("files", file));
|
||||
return request(`/api/v1/tasks/${id}/comments/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text }),
|
||||
}),
|
||||
body: formData,
|
||||
});
|
||||
},
|
||||
updateTaskComment: (id: number, commentId: number, text: string): Promise<TaskComment> =>
|
||||
request(`/api/v1/tasks/${id}/comments/${commentId}/`, {
|
||||
method: "PATCH",
|
||||
|
||||
@ -205,6 +205,101 @@ export interface TaskLabel {
|
||||
color: string;
|
||||
}
|
||||
|
||||
export type TaskAutomationKind = "event" | "schedule" | "button";
|
||||
export type TaskAutomationTrigger =
|
||||
| "manual"
|
||||
| "task_created"
|
||||
| "task_updated"
|
||||
| "task_moved"
|
||||
| "label_added"
|
||||
| "label_removed"
|
||||
| "assignee_changed"
|
||||
| "due_date_changed"
|
||||
| "priority_changed"
|
||||
| "attachment_added"
|
||||
| "comment_added"
|
||||
| "checklist_completed"
|
||||
| "linked_object_added"
|
||||
| "date_reached";
|
||||
|
||||
export type TaskAutomationCondition =
|
||||
| {
|
||||
operator: "and" | "or" | "not";
|
||||
children: TaskAutomationCondition[];
|
||||
}
|
||||
| {
|
||||
field: string;
|
||||
operator: string;
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
export interface TaskAutomationAction {
|
||||
type: string;
|
||||
target?: "column" | "column_name" | "first" | "final";
|
||||
column_id?: number;
|
||||
column_name?: string;
|
||||
user_id?: number | null;
|
||||
label_id?: number;
|
||||
priority?: TaskPriority;
|
||||
mode?: "clear" | "fixed" | "relative";
|
||||
date?: string;
|
||||
days?: number;
|
||||
text?: string;
|
||||
items?: string[];
|
||||
}
|
||||
|
||||
export interface TaskAutomation {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
kind: TaskAutomationKind;
|
||||
trigger: TaskAutomationTrigger;
|
||||
conditions: TaskAutomationCondition;
|
||||
actions: TaskAutomationAction[];
|
||||
schedule_config: {
|
||||
mode?: "task_due_date" | "fixed_datetime";
|
||||
days_before?: number;
|
||||
time?: string;
|
||||
at?: string;
|
||||
};
|
||||
applies_to_all_boards: boolean;
|
||||
boards: number[];
|
||||
is_active: boolean;
|
||||
stop_on_error: boolean;
|
||||
position: number;
|
||||
created_by?: User;
|
||||
last_run_at?: string | null;
|
||||
runs_count?: number;
|
||||
can_manage?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TaskAutomationRun {
|
||||
id: number;
|
||||
automation: number;
|
||||
automation_name: string;
|
||||
task: number;
|
||||
task_title: string;
|
||||
actor?: User | null;
|
||||
source: "event" | "schedule" | "manual";
|
||||
status: "running" | "success" | "partial" | "failed" | "skipped";
|
||||
context: Record<string, unknown>;
|
||||
actions_log: Array<Record<string, unknown>>;
|
||||
error?: string;
|
||||
started_at: string;
|
||||
finished_at?: string | null;
|
||||
}
|
||||
|
||||
export interface TaskAutomationCatalog {
|
||||
kinds: Record<TaskAutomationKind, string>;
|
||||
event_triggers: Record<string, string>;
|
||||
condition_fields: Record<string, string>;
|
||||
condition_operators: Record<string, string>;
|
||||
action_types: Record<string, string>;
|
||||
priorities: Record<TaskPriority, string>;
|
||||
}
|
||||
|
||||
export interface TaskCard {
|
||||
id: number;
|
||||
board: number;
|
||||
@ -537,6 +632,7 @@ export interface TaskActivity {
|
||||
export interface TaskAttachment {
|
||||
id: number;
|
||||
task: number;
|
||||
comment?: number | null;
|
||||
file_name: string;
|
||||
file_size: number;
|
||||
mime_type?: string;
|
||||
@ -567,6 +663,7 @@ export interface TaskComment {
|
||||
is_edited?: boolean;
|
||||
edited_at?: string | null;
|
||||
created_at: string;
|
||||
attachments?: TaskAttachment[];
|
||||
}
|
||||
|
||||
export interface MessageLinkedTask {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user