From 6b0643eb754c47645dc334da967f02edc1a8fb2f Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 6 Aug 2026 12:52:42 +0300 Subject: [PATCH] feat(tasks): add automations and comment resources --- backend/api/v1/tasks/serializers.py | 124 ++++ backend/api/v1/tasks/urls.py | 2 + backend/api/v1/tasks/views.py | 257 ++++++- backend/eusrr_backend/settings.py | 4 + backend/tasks/admin.py | 41 ++ backend/tasks/automation/__init__.py | 11 + backend/tasks/automation/engine.py | 593 ++++++++++++++++ backend/tasks/automation/schema.py | 216 ++++++ backend/tasks/comment_resources.py | 73 ++ ...skautomation_taskautomationrun_and_more.py | 86 +++ .../migrations/0032_taskattachment_comment.py | 20 + backend/tasks/models.py | 169 +++++ backend/tasks/signals.py | 17 +- backend/tasks/tasks.py | 14 + .../api/v1/tasks/test_task_automations.py | 279 ++++++++ backend/tests/api/v1/tasks/test_tasks_api.py | 89 +++ frontend/src/app/tasks/page.tsx | 303 ++++++-- .../src/components/shared/CommentControls.tsx | 79 ++- .../tasks/TaskAutomationManager.tsx | 644 ++++++++++++++++++ .../tasks/TaskCommentAttachment.tsx | 127 ++++ frontend/src/lib/api/tasks.ts | 52 +- frontend/src/types/api.ts | 97 +++ 22 files changed, 3222 insertions(+), 75 deletions(-) create mode 100644 backend/tasks/automation/__init__.py create mode 100644 backend/tasks/automation/engine.py create mode 100644 backend/tasks/automation/schema.py create mode 100644 backend/tasks/comment_resources.py create mode 100644 backend/tasks/migrations/0031_taskautomation_taskautomationrun_and_more.py create mode 100644 backend/tasks/migrations/0032_taskattachment_comment.py create mode 100644 backend/tests/api/v1/tasks/test_task_automations.py create mode 100644 frontend/src/components/tasks/TaskAutomationManager.tsx create mode 100644 frontend/src/components/tasks/TaskCommentAttachment.tsx diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 2504bc08..5cb780d0 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -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", diff --git a/backend/api/v1/tasks/urls.py b/backend/api/v1/tasks/urls.py index 6a4fc7b7..7a75db14 100644 --- a/backend/api/v1/tasks/urls.py +++ b/backend/api/v1/tasks/urls.py @@ -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") diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index e44bcc12..d5d0e0c0 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -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, diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index de58e854..5381afbe 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -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 diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index 021609f6..4991afe4 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -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 = [ diff --git a/backend/tasks/automation/__init__.py b/backend/tasks/automation/__init__.py new file mode 100644 index 00000000..aadc653b --- /dev/null +++ b/backend/tasks/automation/__init__.py @@ -0,0 +1,11 @@ +from .engine import ( + dispatch_scheduled_automations, + execute_automation, + process_activity_automations, +) + +__all__ = [ + "dispatch_scheduled_automations", + "execute_automation", + "process_activity_automations", +] diff --git a/backend/tasks/automation/engine.py b/backend/tasks/automation/engine.py new file mode 100644 index 00000000..a72c8b4b --- /dev/null +++ b/backend/tasks/automation/engine.py @@ -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 diff --git a/backend/tasks/automation/schema.py b/backend/tasks/automation/schema.py new file mode 100644 index 00000000..646a59a9 --- /dev/null +++ b/backend/tasks/automation/schema.py @@ -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), + } diff --git a/backend/tasks/comment_resources.py b/backend/tasks/comment_resources.py new file mode 100644 index 00000000..56dda5a0 --- /dev/null +++ b/backend/tasks/comment_resources.py @@ -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 diff --git a/backend/tasks/migrations/0031_taskautomation_taskautomationrun_and_more.py b/backend/tasks/migrations/0031_taskautomation_taskautomationrun_and_more.py new file mode 100644 index 00000000..d7c339ed --- /dev/null +++ b/backend/tasks/migrations/0031_taskautomation_taskautomationrun_and_more.py @@ -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'), + ), + ] diff --git a/backend/tasks/migrations/0032_taskattachment_comment.py b/backend/tasks/migrations/0032_taskattachment_comment.py new file mode 100644 index 00000000..d5c2b6da --- /dev/null +++ b/backend/tasks/migrations/0032_taskattachment_comment.py @@ -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='Комментарий'), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index f2dd81f3..d8677ac1 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -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/", diff --git a/backend/tasks/signals.py b/backend/tasks/signals.py index 464c0868..a6827dbd 100644 --- a/backend/tasks/signals.py +++ b/backend/tasks/signals.py @@ -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) diff --git a/backend/tasks/tasks.py b/backend/tasks/tasks.py index 87df4269..2b79136f 100644 --- a/backend/tasks/tasks.py +++ b/backend/tasks/tasks.py @@ -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() diff --git a/backend/tests/api/v1/tasks/test_task_automations.py b/backend/tests/api/v1/tasks/test_task_automations.py new file mode 100644 index 00000000..04175162 --- /dev/null +++ b/backend/tests/api/v1/tasks/test_task_automations.py @@ -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 diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 9255660a..26e1d353 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -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" diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 11b1d183..3a50ba26 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -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 ( +

+ {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 ( + + + {href} + + {trailing} + + ); + })} +

+ ); +} + 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(emptyForm); @@ -2969,6 +3002,7 @@ function TasksPageContent() { const [editingCommentDraft, setEditingCommentDraft] = useState(""); const [taskCommentsLoading, setTaskCommentsLoading] = useState(false); const [commentDraft, setCommentDraft] = useState(""); + const [commentFiles, setCommentFiles] = useState([]); const [attachmentsOpen, setAttachmentsOpen] = useState(false); const [taskAttachments, setTaskAttachments] = useState([]); 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([]); const [taskActivityLoading, setTaskActivityLoading] = useState(false); + const [taskAutomationButtons, setTaskAutomationButtons] = useState([]); + const [taskAutomationButtonsLoading, setTaskAutomationButtonsLoading] = useState(false); + const [runningTaskAutomationId, setRunningTaskAutomationId] = useState(null); const boardActionsMenuRef = useRef(null); const taskMenuRef = useRef(null); const taskAddMenuRef = useRef(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() { Добавить колонку + {user?.auth?.is_staff || user?.auth?.is_superuser ? ( + + ) : null} + {user?.auth?.is_staff || user?.auth?.is_superuser ? ( + + ) : null} ) : null} @@ -7166,6 +7320,28 @@ function TasksPageContent() { )} ) : null} + {taskAutomationButtonsLoading ? ( + + + Действия + + ) : taskAutomationButtons.map((automation) => ( + + ))} {viewTaskColumn ? (
- ) : ( -

- {comment.text} -

- )} + ) : comment.text ? ( + + ) : null} + {comment.attachments?.length ? ( +
+ {comment.attachments.map((attachment) => ( + + ))} +
+ ) : null} ); })} @@ -7841,7 +8027,10 @@ function TasksPageContent() { -
-
-

Доска

-

- {board?.name || "Не указана"} -

-
-
-

Создал

-

- {viewTask.created_by ? displayUserName(viewTask.created_by) : "Не указан"} -

-
-
-

Дата создания

-

- {formatDate(viewTask.created_at)} -

-
-
-

Колонка

-

- {viewTaskColumn?.name || viewTask.column_name || "Не указана"} -

-
-
-

Дорожка

-

- {viewTaskRow?.name || "Без дорожки"} -

-
- {viewTask.completed_at ? ( -
-

Завершено

-

- {formatDate(viewTask.completed_at)} -

+
+ + + {taskDetailsOpen ? ( +
+
+

Доска

+

+ {board?.name || "Не указана"} +

+
+
+

Создал

+

+ {viewTask.created_by ? displayUserName(viewTask.created_by) : "Не указан"} +

+
+
+

Дата создания

+

+ {formatDate(viewTask.created_at)} +

+
+
+

Колонка

+

+ {viewTaskColumn?.name || viewTask.column_name || "Не указана"} +

+
+
+

Дорожка

+

+ {viewTaskRow?.name || "Без дорожки"} +

+
+ {viewTask.completed_at ? ( +
+

Завершено

+

+ {formatDate(viewTask.completed_at)} +

+
+ ) : null}
) : null}
diff --git a/frontend/src/components/shared/CommentControls.tsx b/frontend/src/components/shared/CommentControls.tsx index 0097ccd3..1e7dda48 100644 --- a/frontend/src/components/shared/CommentControls.tsx +++ b/frontend/src/components/shared/CommentControls.tsx @@ -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; 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(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 (
+ {onFilesChange ? ( + { + appendFiles(Array.from(event.currentTarget.files || [])); + event.currentTarget.value = ""; + }} + /> + ) : null}