feat(tasks): allow users to manage automations

This commit is contained in:
lizerk 2026-08-06 15:42:39 +03:00
parent 0d252753a3
commit ee48bf870e
4 changed files with 67 additions and 36 deletions

View File

@ -144,6 +144,12 @@ class TaskAutomationRunSerializer(serializers.ModelSerializer):
class TaskAutomationSerializer(serializers.ModelSerializer):
"""Validate shared automation rules exposed to authenticated task users.
``can_manage`` intentionally mirrors the temporary broad API permission. It
should use the dedicated automation permission once that role is introduced.
"""
created_by = EmployeeBriefSerializer(read_only=True)
boards = serializers.PrimaryKeyRelatedField(
queryset=TaskBoard.objects.filter(is_archived=False),
@ -188,7 +194,7 @@ class TaskAutomationSerializer(serializers.ModelSerializer):
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))
return bool(user and user.is_authenticated)
def validate_name(self, value):
name = value.strip()

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 IsAdminUser, IsAuthenticated
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from schedule.models import Event
@ -186,17 +186,22 @@ def send_task_checklist_update(task, event, item_id):
class TaskAutomationViewSet(viewsets.ModelViewSet):
"""Manage shared task automation rules.
Automation management is temporarily available to every authenticated user.
Rules are system-wide and may target multiple boards, so this broad access must
be replaced with a dedicated permission before permission roles are finalized.
"""
# TODO(tasks-automation-permissions): Replace broad authenticated access with
# a dedicated permission/role for managing shared automation rules.
permission_classes = [IsAuthenticated]
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")

View File

@ -213,7 +213,7 @@ def test_scheduled_automation_runs_relative_to_task_due_date(task_setup):
assert run.context["scheduled_for"] == local_run_at.isoformat()
def test_regular_user_can_run_eligible_button_but_cannot_manage_rules(
def test_regular_user_can_manage_rules_and_run_eligible_button(
api_client,
user_factory,
task_setup,
@ -232,6 +232,19 @@ def test_regular_user_can_run_eligible_button_but_cannot_manage_rules(
api_client.force_authenticate(employee)
list_response = api_client.get(reverse("api:v1:tasks:task-automation-list"))
create_response = api_client.post(
reverse("api:v1:tasks:task-automation-list"),
{
"name": "Правило сотрудника",
"kind": "event",
"trigger": "task_updated",
"conditions": {"operator": "and", "children": []},
"actions": [{"type": "set_priority", "priority": "medium"}],
"applies_to_all_boards": False,
"boards": [board.id],
},
format="json",
)
buttons_response = api_client.get(
reverse("api:v1:tasks:task-automation-buttons"),
{"task": task.id},
@ -242,7 +255,10 @@ def test_regular_user_can_run_eligible_button_but_cannot_manage_rules(
format="json",
)
assert list_response.status_code == status.HTTP_403_FORBIDDEN
assert list_response.status_code == status.HTTP_200_OK
assert create_response.status_code == status.HTTP_201_CREATED
assert create_response.data["created_by"]["id"] == employee.id
assert create_response.data["can_manage"] is True
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
@ -250,6 +266,12 @@ def test_regular_user_can_run_eligible_button_but_cannot_manage_rules(
assert task.priority == "high"
def test_automation_management_requires_authentication(api_client):
response = api_client.get(reverse("api:v1:tasks:task-automation-list"))
assert response.status_code == status.HTTP_401_UNAUTHORIZED
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")

View File

@ -6640,19 +6640,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}
{/* TODO(tasks-automation-permissions): Gate this action by the
dedicated automation-management capability once introduced. */}
<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>
<button
type="button"
onClick={() => board && void setDefaultBoard(board)}
@ -7600,20 +7600,18 @@ 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}
<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>
</div>
) : null}
</div>