feat(tasks): add automations and comment resources

This commit is contained in:
lizerk 2026-08-06 12:52:42 +03:00
parent 45866bb69c
commit 6b0643eb75
22 changed files with 3222 additions and 75 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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

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

View 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

View File

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

View 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='Комментарий'),
),
]

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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