diff --git a/.github/sql-fixtures/fixtures.sql b/.github/sql-fixtures/fixtures.sql index bd54cef7f..4450468c4 100644 Binary files a/.github/sql-fixtures/fixtures.sql and b/.github/sql-fixtures/fixtures.sql differ diff --git a/python/apps/taiga/src/taiga/conf/__init__.py b/python/apps/taiga/src/taiga/conf/__init__.py index dc89eaba3..09ccdcd97 100644 --- a/python/apps/taiga/src/taiga/conf/__init__.py +++ b/python/apps/taiga/src/taiga/conf/__init__.py @@ -88,6 +88,9 @@ class Settings(BaseSettings): INVITATION_RESEND_LIMIT: int = 10 INVITATION_RESEND_TIME: int = 10 # 10 minutes + # Workflows + MAX_NUM_WORKFLOWS: int = 8 + # Tasks (linux crontab style) CLEAN_EXPIRED_TOKENS_CRON: str = "0 0 * * *" # default: once a day CLEAN_EXPIRED_USERS_CRON: str = "0 0 * * *" # default: once a day diff --git a/python/apps/taiga/src/taiga/projects/projects/fixtures/initial_project_templates.json b/python/apps/taiga/src/taiga/projects/projects/fixtures/initial_project_templates.json index 649887492..01e3ff4ea 100644 --- a/python/apps/taiga/src/taiga/projects/projects/fixtures/initial_project_templates.json +++ b/python/apps/taiga/src/taiga/projects/projects/fixtures/initial_project_templates.json @@ -37,14 +37,14 @@ { "slug": "main", "name": "Main", - "order": 1, - "statuses": [ - {"name": "New", "order": 1, "color": 1}, - {"name": "Ready", "order": 2, "color": 2}, - {"name": "In progress", "order": 3, "color": 3}, - {"name": "Done", "order": 4, "color": 4} - ] + "order": 1 } + ], + "workflow_statuses": [ + {"name": "New", "order": 1, "color": 1}, + {"name": "Ready", "order": 2, "color": 2}, + {"name": "In progress", "order": 3, "color": 3}, + {"name": "Done", "order": 4, "color": 4} ] } } diff --git a/python/apps/taiga/src/taiga/projects/projects/migrations/0007_projecttemplate_workflow_statuses.py b/python/apps/taiga/src/taiga/projects/projects/migrations/0007_projecttemplate_workflow_statuses.py new file mode 100644 index 000000000..d4dd5aaed --- /dev/null +++ b/python/apps/taiga/src/taiga/projects/projects/migrations/0007_projecttemplate_workflow_statuses.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2023-present Kaleidos INC + +# Generated by Django 4.2.3 on 2023-09-14 12:48 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("projects", "0006_alter_project_created_by_alter_project_workspace"), + ] + + operations = [ + migrations.AddField( + model_name="projecttemplate", + name="workflow_statuses", + field=django.contrib.postgres.fields.jsonb.JSONField( + blank=True, null=True, verbose_name="workflow statuses" + ), + ), + ] diff --git a/python/apps/taiga/src/taiga/projects/projects/models.py b/python/apps/taiga/src/taiga/projects/projects/models.py index e42fd4d89..9b52e33e0 100644 --- a/python/apps/taiga/src/taiga/projects/projects/models.py +++ b/python/apps/taiga/src/taiga/projects/projects/models.py @@ -99,6 +99,7 @@ class ProjectTemplate(models.BaseModel): slug = models.LowerSlugField(max_length=250, null=False, blank=True, unique=True, verbose_name="slug") roles = models.JSONField(null=True, blank=True, verbose_name="roles") workflows = models.JSONField(null=True, blank=True, verbose_name="workflows") + workflow_statuses = models.JSONField(null=True, blank=True, verbose_name="workflow statuses") class Meta: verbose_name = "project template" diff --git a/python/apps/taiga/src/taiga/projects/projects/repositories.py b/python/apps/taiga/src/taiga/projects/projects/repositories.py index fe8306b69..dee55f15d 100644 --- a/python/apps/taiga/src/taiga/projects/projects/repositories.py +++ b/python/apps/taiga/src/taiga/projects/projects/repositories.py @@ -81,7 +81,7 @@ def _apply_select_related_to_project_queryset( return qs.select_related(*select_related) -ProjectPrefetchRelated = list[Literal["workspace",]] +ProjectPrefetchRelated = list[Literal["workflows"]] def _apply_prefetch_related_to_project_queryset( @@ -166,7 +166,7 @@ def list_projects( def get_project( filters: ProjectFilters = {}, select_related: ProjectSelectRelated = ["workspace"], - prefetch_related: ProjectPrefetchRelated = ["workspace"], + prefetch_related: ProjectPrefetchRelated = [], ) -> Project | None: qs = _apply_filters_to_project_queryset(qs=DEFAULT_QUERYSET, filters=filters) qs = _apply_select_related_to_project_queryset(qs=qs, select_related=select_related) @@ -278,7 +278,7 @@ def apply_template_to_project_sync(template: ProjectTemplate, project: Project) order=workflow["order"], project=project, ) - for status in workflow["statuses"]: + for status in template.workflow_statuses: workflows_repositories.create_workflow_status_sync( name=status["name"], color=status["color"], diff --git a/python/apps/taiga/src/taiga/projects/projects/serializers/__init__.py b/python/apps/taiga/src/taiga/projects/projects/serializers/__init__.py index b049e325e..4a63c5a9b 100644 --- a/python/apps/taiga/src/taiga/projects/projects/serializers/__init__.py +++ b/python/apps/taiga/src/taiga/projects/projects/serializers/__init__.py @@ -7,6 +7,7 @@ from taiga.base.serializers import UUIDB64, BaseModel from taiga.projects.projects.serializers.mixins import ProjectLogoMixin +from taiga.workflows.serializers.nested import WorkflowNestedSerializer from taiga.workspaces.workspaces.serializers.nested import WorkspaceNestedSerializer @@ -28,6 +29,7 @@ class ProjectDetailSerializer(BaseModel, ProjectLogoMixin): description: str color: int workspace: WorkspaceNestedSerializer + workflows: list[WorkflowNestedSerializer] # User related fields user_is_admin: bool diff --git a/python/apps/taiga/src/taiga/projects/projects/serializers/services.py b/python/apps/taiga/src/taiga/projects/projects/serializers/services.py index bde45fac0..ad271f4cf 100644 --- a/python/apps/taiga/src/taiga/projects/projects/serializers/services.py +++ b/python/apps/taiga/src/taiga/projects/projects/serializers/services.py @@ -7,12 +7,14 @@ from taiga.projects.projects.models import Project from taiga.projects.projects.serializers import ProjectDetailSerializer +from taiga.workflows.models import Workflow from taiga.workspaces.workspaces.serializers.nested import WorkspaceNestedSerializer def serialize_project_detail( project: Project, workspace: WorkspaceNestedSerializer, + workflows: list[Workflow], user_is_admin: bool, user_is_member: bool, user_has_pending_invitation: bool, @@ -26,6 +28,7 @@ def serialize_project_detail( color=project.color, logo=project.logo, workspace=workspace, + workflows=workflows, user_is_admin=user_is_admin, user_is_member=user_is_member, user_has_pending_invitation=user_has_pending_invitation, diff --git a/python/apps/taiga/src/taiga/projects/projects/services/__init__.py b/python/apps/taiga/src/taiga/projects/projects/services/__init__.py index 4c709c864..7587a0885 100644 --- a/python/apps/taiga/src/taiga/projects/projects/services/__init__.py +++ b/python/apps/taiga/src/taiga/projects/projects/services/__init__.py @@ -29,6 +29,7 @@ from taiga.projects.roles import repositories as pj_roles_repositories from taiga.users import services as users_services from taiga.users.models import AnyUser, User +from taiga.workflows import repositories as workflows_repositories from taiga.workspaces.memberships import repositories as workspace_memberships_repositories from taiga.workspaces.workspaces import services as workspaces_services from taiga.workspaces.workspaces.models import Workspace @@ -99,7 +100,7 @@ async def _create_project( async def list_projects(workspace_id: UUID) -> list[Project]: return await projects_repositories.list_projects( filters={"workspace_id": workspace_id}, - prefetch_related=["workspace"], + select_related=["workspace"], ) @@ -113,7 +114,7 @@ async def list_workspace_projects_for_user(workspace: Workspace, user: User) -> return await projects_repositories.list_projects( filters={"workspace_id": workspace.id, "project_member_id": user.id}, - prefetch_related=["workspace"], + select_related=["workspace"], ) @@ -134,8 +135,7 @@ async def list_workspace_invited_projects_for_user(workspace: Workspace, user: U async def get_project(id: UUID) -> Project | None: return await projects_repositories.get_project( - filters={"id": id}, - select_related=["workspace"], + filters={"id": id}, select_related=["workspace"], prefetch_related=["workflows"] ) @@ -166,9 +166,16 @@ async def get_project_detail(project: Project, user: AnyUser) -> ProjectDetailSe else await pj_invitations_services.has_pending_project_invitation(user=user, project=project) ) + workflows = await workflows_repositories.list_workflows( + filters={ + "project_id": project.id, + } + ) + return serializers_services.serialize_project_detail( project=project, workspace=workspace, + workflows=workflows, user_is_admin=is_project_admin, user_is_member=is_project_member, user_permissions=user_permissions, diff --git a/python/apps/taiga/src/taiga/workflows/api/__init__.py b/python/apps/taiga/src/taiga/workflows/api/__init__.py index 32e1db5ae..e79ae5e53 100644 --- a/python/apps/taiga/src/taiga/workflows/api/__init__.py +++ b/python/apps/taiga/src/taiga/workflows/api/__init__.py @@ -18,15 +18,17 @@ from taiga.routers import routes from taiga.workflows import services as workflows_services from taiga.workflows.api.validators import ( + CreateWorkflowStatusValidator, + CreateWorkflowValidator, DeleteWorkflowStatusQuery, ReorderWorkflowStatusesValidator, UpdateWorkflowStatusValidator, - WorkflowStatusValidator, ) from taiga.workflows.models import Workflow, WorkflowStatus from taiga.workflows.serializers import ReorderWorkflowStatusesSerializer, WorkflowSerializer, WorkflowStatusSerializer # PERMISSIONS +CREATE_WORKFLOW = IsProjectAdmin() LIST_WORKFLOWS = HasPerm("view_story") GET_WORKFLOW = HasPerm("view_story") CREATE_WORKFLOW_STATUS = IsProjectAdmin() @@ -40,25 +42,54 @@ REORDER_WORKFLOW_STATUSES_200 = responses.http_status_200(model=ReorderWorkflowStatusesSerializer) +################################################ +# create workflow +################################################ + + +@routes.workflows.post( + "/projects/{project_id}/workflows", + name="project.workflow.create", + summary="Create workflows", + responses=GET_WORKFLOW_200 | ERROR_403 | ERROR_404 | ERROR_422, + response_model=None, +) +async def create_workflow( + project_id: B64UUID, + request: AuthRequest, + form: CreateWorkflowValidator, +) -> WorkflowSerializer: + """ + Creates a workflow for a project + """ + project = await get_project_or_404(project_id) + await check_permissions(permissions=CREATE_WORKFLOW, user=request.user, obj=project) + + return await workflows_services.create_workflow( + name=form.name, + project=project, + ) + + ################################################ # list workflows ################################################ @routes.workflows.get( - "/projects/{id}/workflows", + "/projects/{project_id}/workflows", name="project.workflow.list", summary="List workflows", responses=LIST_WORKFLOW_200 | ERROR_403 | ERROR_404 | ERROR_422, response_model=None, ) -async def list_workflows(id: B64UUID, request: Request) -> list[WorkflowSerializer]: +async def list_workflows(project_id: B64UUID, request: Request) -> list[WorkflowSerializer]: """ List the workflows of a project """ - project = await get_project_or_404(id) + project = await get_project_or_404(project_id) await check_permissions(permissions=LIST_WORKFLOWS, user=request.user, obj=project) - return await workflows_services.list_workflows(project_id=id) + return await workflows_services.list_workflows(project_id=project_id) ################################################ @@ -67,23 +98,23 @@ async def list_workflows(id: B64UUID, request: Request) -> list[WorkflowSerializ @routes.workflows.get( - "/projects/{id}/workflows/{workflow_slug}", + "/projects/{project_id}/workflows/{workflow_slug}", name="project.workflow.get", summary="Get project workflow", responses=GET_WORKFLOW_200 | ERROR_403 | ERROR_404 | ERROR_422, response_model=None, ) async def get_workflow( - id: B64UUID, + project_id: B64UUID, workflow_slug: str, request: Request, ) -> WorkflowSerializer: """ Get the details of a workflow """ - workflow = await get_workflow_or_404(project_id=id, workflow_slug=workflow_slug) + workflow = await get_workflow_or_404(project_id=project_id, workflow_slug=workflow_slug) await check_permissions(permissions=GET_WORKFLOW, user=request.user, obj=workflow) - return await workflows_services.get_workflow_detail(project_id=id, workflow_slug=workflow_slug) + return await workflows_services.get_workflow_detail(project_id=project_id, workflow_slug=workflow_slug) ################################################ @@ -115,7 +146,7 @@ async def create_workflow_status( project_id: B64UUID, workflow_slug: str, request: AuthRequest, - form: WorkflowStatusValidator, + form: CreateWorkflowStatusValidator, ) -> WorkflowStatus: """ Creates a workflow status in the given project workflow diff --git a/python/apps/taiga/src/taiga/workflows/api/validators.py b/python/apps/taiga/src/taiga/workflows/api/validators.py index ca50f9f32..af875e1b3 100644 --- a/python/apps/taiga/src/taiga/workflows/api/validators.py +++ b/python/apps/taiga/src/taiga/workflows/api/validators.py @@ -15,19 +15,29 @@ from taiga.exceptions import api as ex -class Name(ConstrainedStr): +class WorkflowStatusName(ConstrainedStr): strip_whitespace = True min_length = 1 max_length = 30 -class WorkflowStatusValidator(BaseModel): - name: Name +class WorkflowName(ConstrainedStr): + strip_whitespace = True + min_length = 1 + max_length = 40 + + +class CreateWorkflowValidator(BaseModel): + name: WorkflowName + + +class CreateWorkflowStatusValidator(BaseModel): + name: WorkflowStatusName color: conint(gt=0, lt=9) # type: ignore class UpdateWorkflowStatusValidator(BaseModel): - name: Name | None + name: WorkflowStatusName | None class ReorderValidator(BaseModel): diff --git a/python/apps/taiga/src/taiga/workflows/events/__init__.py b/python/apps/taiga/src/taiga/workflows/events/__init__.py index e5b090163..cbe0d354f 100644 --- a/python/apps/taiga/src/taiga/workflows/events/__init__.py +++ b/python/apps/taiga/src/taiga/workflows/events/__init__.py @@ -8,20 +8,30 @@ from taiga.events import events_manager from taiga.projects.projects.models import Project from taiga.workflows.events.content import ( + CreateWorkflowContent, CreateWorkflowStatusContent, DeleteWorkflowStatusContent, ReorderWorkflowStatusesContent, UpdateWorkflowStatusContent, ) from taiga.workflows.models import WorkflowStatus -from taiga.workflows.serializers import ReorderWorkflowStatusesSerializer +from taiga.workflows.serializers import ReorderWorkflowStatusesSerializer, WorkflowSerializer +CREATE_WORKFLOW = "workflows.create" CREATE_WORKFLOW_STATUS = "workflowstatuses.create" UPDATE_WORKFLOW_STATUS = "workflowstatuses.update" REORDER_WORKFLOW_STATUS = "workflowstatuses.reorder" DELETE_WORKFLOW_STATUS = "workflowstatuses.delete" +async def emit_event_when_workflow_is_created(project: Project, workflow: WorkflowSerializer) -> None: + await events_manager.publish_on_project_channel( + project=project, + type=CREATE_WORKFLOW, + content=CreateWorkflowContent(workflow=workflow), + ) + + async def emit_event_when_workflow_status_is_created(project: Project, workflow_status: WorkflowStatus) -> None: await events_manager.publish_on_project_channel( project=project, diff --git a/python/apps/taiga/src/taiga/workflows/events/content.py b/python/apps/taiga/src/taiga/workflows/events/content.py index 23e32de3c..be829a24d 100644 --- a/python/apps/taiga/src/taiga/workflows/events/content.py +++ b/python/apps/taiga/src/taiga/workflows/events/content.py @@ -6,7 +6,11 @@ # Copyright (c) 2023-present Kaleidos INC from taiga.base.serializers import BaseModel -from taiga.workflows.serializers import ReorderWorkflowStatusesSerializer, WorkflowStatusSerializer +from taiga.workflows.serializers import ReorderWorkflowStatusesSerializer, WorkflowSerializer, WorkflowStatusSerializer + + +class CreateWorkflowContent(BaseModel): + workflow: WorkflowSerializer class CreateWorkflowStatusContent(BaseModel): diff --git a/python/apps/taiga/src/taiga/workflows/migrations/0004_remove_workflow_workflows_workflow_unique_project_name_and_more.py b/python/apps/taiga/src/taiga/workflows/migrations/0004_remove_workflow_workflows_workflow_unique_project_name_and_more.py new file mode 100644 index 000000000..330babf8b --- /dev/null +++ b/python/apps/taiga/src/taiga/workflows/migrations/0004_remove_workflow_workflows_workflow_unique_project_name_and_more.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2023-present Kaleidos INC + +# Generated by Django 4.2.3 on 2023-09-07 10:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("workflows", "0003_remove_workflowstatus_workflows_workflowstatus_unique_workflow_slug_and_more"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="workflow", + name="workflows_workflow_unique_project_name", + ), + migrations.AlterField( + model_name="workflow", + name="order", + field=models.DecimalField(decimal_places=10, default=100, max_digits=16, verbose_name="order"), + ), + ] diff --git a/python/apps/taiga/src/taiga/workflows/models.py b/python/apps/taiga/src/taiga/workflows/models.py index 1236ea924..1c571f756 100644 --- a/python/apps/taiga/src/taiga/workflows/models.py +++ b/python/apps/taiga/src/taiga/workflows/models.py @@ -5,15 +5,19 @@ # # Copyright (c) 2023-present Kaleidos INC +from typing import Any + from taiga.base.db import models -from taiga.base.utils.datetime import timestamp_mics +from taiga.base.utils.slug import generate_incremental_int_suffix, slugify_uniquely_for_queryset from taiga.projects.projects.models import Project class Workflow(models.BaseModel): name = models.CharField(max_length=250, null=False, blank=False, verbose_name="name") slug = models.LowerSlugField(max_length=250, null=False, blank=False, verbose_name="slug") - order = models.BigIntegerField(default=timestamp_mics, null=False, blank=False, verbose_name="order") + order = models.DecimalField( + max_digits=16, decimal_places=10, default=100, null=False, blank=False, verbose_name="order" + ) project = models.ForeignKey( "projects.Project", null=False, @@ -28,7 +32,6 @@ class Meta: verbose_name_plural = "workflows" constraints = [ models.UniqueConstraint(fields=["project", "slug"], name="%(app_label)s_%(class)s_unique_project_slug"), - models.UniqueConstraint(fields=["project", "name"], name="%(app_label)s_%(class)s_unique_project_name"), ] indexes = [ models.Index(fields=["project", "slug"]), @@ -41,6 +44,17 @@ def __str__(self) -> str: def __repr__(self) -> str: return f"" + def save(self, *args: Any, **kwargs: Any) -> None: + if not self.slug: + self.slug = slugify_uniquely_for_queryset( + value=self.name, + queryset=self.project.workflows.all(), + generate_suffix=generate_incremental_int_suffix(), + use_always_suffix=False, + ) + + super().save(*args, **kwargs) + class WorkflowStatus(models.BaseModel): name = models.CharField(max_length=30, null=False, blank=False, verbose_name="name") diff --git a/python/apps/taiga/src/taiga/workflows/repositories.py b/python/apps/taiga/src/taiga/workflows/repositories.py index 216329443..8e6a7fa56 100644 --- a/python/apps/taiga/src/taiga/workflows/repositories.py +++ b/python/apps/taiga/src/taiga/workflows/repositories.py @@ -18,7 +18,7 @@ from taiga.base.db.models import QuerySet from taiga.base.repositories import neighbors as neighbors_repositories from taiga.base.repositories.neighbors import Neighbor -from taiga.projects.projects.models import Project +from taiga.projects.projects.models import Project, ProjectTemplate from taiga.workflows.models import Workflow, WorkflowStatus ########################################################## @@ -68,7 +68,7 @@ def _apply_prefetch_related_to_workflow_queryset( return qs.prefetch_related(*prefetch_related) -WorkflowOrderBy = list[Literal["order",]] +WorkflowOrderBy = list[Literal["order", "-order"]] def _apply_order_by_to_workflow_queryset( @@ -86,7 +86,7 @@ def _apply_order_by_to_workflow_queryset( def create_workflow_sync( name: str, slug: str, - order: int, + order: Decimal, project: Project, ) -> Workflow: return Workflow.objects.create( @@ -97,7 +97,16 @@ def create_workflow_sync( ) -create_workflow = sync_to_async(create_workflow_sync) +async def create_workflow( + name: str, + order: Decimal, + project: Project, +) -> Workflow: + return await Workflow.objects.acreate( + name=name, + order=order, + project=project, + ) ########################################################## @@ -324,3 +333,21 @@ async def delete_workflow_status(filters: WorkflowStatusFilters = {}) -> int: qs = _apply_filters_to_workflow_status_queryset(qs=DEFAULT_QUERYSET_WORKFLOW_STATUS, filters=filters) count, _ = await qs.adelete() return count + + +########################################################## +# WorkflowStatus - misc +########################################################## + + +def apply_default_workflow_statuses_sync(template: ProjectTemplate, workflow: Workflow) -> None: + for status in template.workflow_statuses: + create_workflow_status_sync( + name=status["name"], + color=status["color"], + order=status["order"], + workflow=workflow, + ) + + +apply_default_workflow_statuses = sync_to_async(apply_default_workflow_statuses_sync) diff --git a/python/apps/taiga/src/taiga/workflows/serializers/services.py b/python/apps/taiga/src/taiga/workflows/serializers/services.py index 9b184f649..7b58613a0 100644 --- a/python/apps/taiga/src/taiga/workflows/serializers/services.py +++ b/python/apps/taiga/src/taiga/workflows/serializers/services.py @@ -10,7 +10,7 @@ from uuid import UUID from taiga.workflows.models import Workflow, WorkflowStatus -from taiga.workflows.serializers import ReorderWorkflowStatusesSerializer, WorkflowSerializer +from taiga.workflows.serializers import ReorderWorkflowStatusesSerializer, WorkflowNestedSerializer, WorkflowSerializer def serialize_workflow(workflow: Workflow, workflow_statuses: list[WorkflowStatus] = []) -> WorkflowSerializer: @@ -23,6 +23,13 @@ def serialize_workflow(workflow: Workflow, workflow_statuses: list[WorkflowStatu ) +def serialize_workflow_nested(workflow: Workflow) -> WorkflowNestedSerializer: + return WorkflowNestedSerializer( + name=workflow.name, + slug=workflow.slug, + ) + + def serialize_reorder_workflow_statuses( workflow: Workflow, statuses: list[UUID], reorder: dict[str, Any] | None = None ) -> ReorderWorkflowStatusesSerializer: diff --git a/python/apps/taiga/src/taiga/workflows/services/__init__.py b/python/apps/taiga/src/taiga/workflows/services/__init__.py index bcb8d495b..6f2016f3a 100644 --- a/python/apps/taiga/src/taiga/workflows/services/__init__.py +++ b/python/apps/taiga/src/taiga/workflows/services/__init__.py @@ -10,6 +10,9 @@ from typing import Any, cast from uuid import UUID +from taiga.conf import settings +from taiga.projects.projects import repositories as projects_repositories +from taiga.projects.projects.models import Project from taiga.stories.stories import repositories as stories_repositories from taiga.stories.stories import services as stories_services from taiga.workflows import events as workflows_events @@ -19,10 +22,50 @@ from taiga.workflows.serializers import services as serializers_services from taiga.workflows.services import exceptions as ex -DEFAULT_ORDER_OFFSET = Decimal(100) # default offset when adding a workflow status +DEFAULT_ORDER_OFFSET = Decimal(100) # default offset when adding a workflow or workflow status DEFAULT_PRE_ORDER = Decimal(0) # default pre_position when adding a story at the beginning +########################################################## +# create workflow +########################################################## + + +async def create_workflow(project: Project, name: str) -> WorkflowSerializer: + workflows = await workflows_repositories.list_workflows(filters={"project_id": project.id}, order_by=["-order"]) + + # validate num workflows + num_workflows = len(workflows) if workflows else 0 + if num_workflows >= settings.MAX_NUM_WORKFLOWS: + raise ex.MaxNumWorkflowCreatedError("Maximum number of workflows is reached") + + # calculate order + order = DEFAULT_ORDER_OFFSET + (workflows[0].order if workflows else 0) + + workflow = await workflows_repositories.create_workflow(project=project, name=name, order=order) + + # apply default workflow statuses from project template + if template := await projects_repositories.get_project_template( + filters={"slug": settings.DEFAULT_PROJECT_TEMPLATE} + ): + await workflows_repositories.apply_default_workflow_statuses(template=template, workflow=workflow) + else: + raise Exception( + f"Default project template '{settings.DEFAULT_PROJECT_TEMPLATE}' not found. " + "Try to load fixtures again and check if the error persist." + ) + + workflow_statuses = await workflows_repositories.list_workflow_statuses(filters={"workflow_id": workflow.id}) + serialized_workflow = serializers_services.serialize_workflow( + workflow=workflow, workflow_statuses=workflow_statuses + ) + + # emit event + await workflows_events.emit_event_when_workflow_is_created(project=workflow.project, workflow=serialized_workflow) + + return serialized_workflow + + ########################################################## # list workflows ########################################################## diff --git a/python/apps/taiga/src/taiga/workflows/services/exceptions.py b/python/apps/taiga/src/taiga/workflows/services/exceptions.py index 5e0cfa573..af3aaad3f 100644 --- a/python/apps/taiga/src/taiga/workflows/services/exceptions.py +++ b/python/apps/taiga/src/taiga/workflows/services/exceptions.py @@ -23,3 +23,7 @@ class NonExistingMoveToStatus(TaigaServiceException): class SameMoveToStatus(TaigaServiceException): ... + + +class MaxNumWorkflowCreatedError(TaigaServiceException): + ... diff --git a/python/apps/taiga/tests/integration/taiga/workflows/test_api.py b/python/apps/taiga/tests/integration/taiga/workflows/test_api.py index f5271d0c0..e70770dbe 100644 --- a/python/apps/taiga/tests/integration/taiga/workflows/test_api.py +++ b/python/apps/taiga/tests/integration/taiga/workflows/test_api.py @@ -15,6 +15,48 @@ pytestmark = pytest.mark.django_db +########################################################## +# Workflow POST /projects//workflows +########################################################## + + +async def test_create_workflow_200_ok(client): + project = await f.create_project() + data = {"name": "New workflow"} + + client.login(project.created_by) + response = client.post(f"/projects/{project.b64id}/workflows", json=data) + assert response.status_code == status.HTTP_200_OK, response.text + + +async def test_create_workflow_403_forbidden_permissions(client): + project = await f.create_project() + user = await f.create_user() + data = {"name": "New workflow"} + + client.login(user) + response = client.post(f"/projects/{project.b64id}/workflows", json=data) + assert response.status_code == status.HTTP_403_FORBIDDEN, response.text + + +async def test_create_workflow_404_not_found_project_b64id(client): + user = await f.create_user() + data = {"name": "New workflow"} + + client.login(user) + response = client.post(f"/projects/{NOT_EXISTING_B64ID}/workflows", json=data) + assert response.status_code == status.HTTP_404_NOT_FOUND, response.text + + +async def test_create_workflow_422_unprocessable_project_b64id(client): + user = await f.create_user() + data = {"name": "New workflow"} + + client.login(user) + response = client.post(f"/projects/{INVALID_B64ID}/workflows", json=data) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY, response.text + + ########################################################## # Workflow GET /projects//workflows ########################################################## diff --git a/python/apps/taiga/tests/integration/taiga/workflows/test_repositories.py b/python/apps/taiga/tests/integration/taiga/workflows/test_repositories.py index 11cef85e9..6d4440b65 100644 --- a/python/apps/taiga/tests/integration/taiga/workflows/test_repositories.py +++ b/python/apps/taiga/tests/integration/taiga/workflows/test_repositories.py @@ -26,7 +26,6 @@ async def test_create_workflow(): project = await f.create_project() workflow_res = await repositories.create_workflow( name="workflow", - slug="slug", order=1, project=project, ) diff --git a/python/apps/taiga/tests/unit/taiga/projects/projects/test_services.py b/python/apps/taiga/tests/unit/taiga/projects/projects/test_services.py index b617a987f..3833ac728 100644 --- a/python/apps/taiga/tests/unit/taiga/projects/projects/test_services.py +++ b/python/apps/taiga/tests/unit/taiga/projects/projects/test_services.py @@ -114,7 +114,7 @@ async def test_list_workspace_projects_for_a_ws_member(): await services.list_workspace_projects_for_user(workspace=workspace, user=workspace.created_by) fake_projects_repo.list_projects.assert_awaited_once_with( filters={"workspace_id": workspace.id}, - prefetch_related=["workspace"], + select_related=["workspace"], ) @@ -126,7 +126,7 @@ async def test_list_workspace_projects_not_for_a_ws_member(): await services.list_workspace_projects_for_user(workspace=workspace, user=user) fake_projects_repo.list_projects.assert_awaited_once_with( filters={"workspace_id": workspace.id, "project_member_id": user.id}, - prefetch_related=["workspace"], + select_related=["workspace"], ) diff --git a/python/apps/taiga/tests/unit/taiga/workflows/test_services.py b/python/apps/taiga/tests/unit/taiga/workflows/test_services.py index 38f66d91b..40f1184af 100644 --- a/python/apps/taiga/tests/unit/taiga/workflows/test_services.py +++ b/python/apps/taiga/tests/unit/taiga/workflows/test_services.py @@ -13,6 +13,65 @@ from taiga.workflows.services import exceptions as ex from tests.utils import factories as f +####################################################### +# create_workflow +####################################################### + + +async def test_create_workflow_ok(): + project = f.build_project() + workflow = f.build_workflow(project=project) + + with ( + patch("taiga.workflows.services.workflows_repositories", autospec=True) as fake_workflows_repo, + patch("taiga.workflows.services.projects_repositories", autospec=True) as fake_projects_repo, + patch("taiga.workflows.services.workflows_events", autospec=True) as fake_workflows_events, + ): + fake_workflows_repo.list_workflows.return_value = None + fake_workflows_repo.create_workflow.return_value = workflow + fake_workflows_repo.list_workflow_statuses.return_value = [] + + workflow = await services.create_workflow(project=workflow.project, name=workflow.name) + + fake_workflows_repo.list_workflows.assert_awaited_once_with( + filters={"project_id": project.id}, order_by=["-order"] + ) + fake_workflows_repo.create_workflow.assert_awaited_once_with(project=project, name=workflow.name, order=100) + fake_projects_repo.get_project_template.assert_awaited_once() + fake_workflows_repo.apply_default_workflow_statuses.assert_awaited_once() + fake_workflows_repo.list_workflow_statuses.assert_awaited_once() + + fake_workflows_events.emit_event_when_workflow_is_created.assert_awaited_once_with( + project=project, workflow=workflow + ) + + +async def test_create_workflow_reached_num_workflows_error(override_settings): + project = f.build_project() + workflow1 = f.build_workflow(project=project) + workflow2 = f.build_workflow(project=project) + + with ( + patch("taiga.workflows.services.workflows_repositories", autospec=True) as fake_workflows_repo, + patch("taiga.workflows.services.projects_repositories", autospec=True) as fake_projects_repo, + patch("taiga.workflows.services.workflows_events", autospec=True) as fake_workflows_events, + override_settings({"MAX_NUM_WORKFLOWS": 1}), + pytest.raises(ex.MaxNumWorkflowCreatedError), + ): + fake_workflows_repo.list_workflows.return_value = [workflow1] + + await services.create_workflow(project=workflow2.project, name=workflow2.name) + + fake_workflows_repo.list_workflows.assert_awaited_once_with( + filters={"project_id": project.id}, order_by=["-order"] + ) + fake_workflows_repo.create_workflow.assert_not_awaited() + fake_projects_repo.get_project_template.assert_not_awaited() + fake_workflows_repo.apply_default_workflow_statuses.assert_not_awaited() + fake_workflows_repo.list_workflow_statuses.assert_not_awaited() + fake_workflows_events.emit_event_when_workflow_is_created.assert_not_awaited() + + ####################################################### # list_workflows ####################################################### diff --git a/python/docs/events.md b/python/docs/events.md index 6363d5b51..5bccc0add 100644 --- a/python/docs/events.md +++ b/python/docs/events.md @@ -656,6 +656,19 @@ Content for: ``` +#### `workflows.create` + +It happens when a new workflow has been created. + +Content for: +- project channel: + ``` + { + "workflow": {... "workflow object" ...} + } + ``` + + #### `workflowstatuses.create` It happens when a new workflow status has been created. diff --git a/python/docs/postman/taiga.postman_collection.json b/python/docs/postman/taiga.postman_collection.json index e36cf65ad..1cdfbefa8 100644 --- a/python/docs/postman/taiga.postman_collection.json +++ b/python/docs/postman/taiga.postman_collection.json @@ -1,9 +1,9 @@ { "info": { - "_postman_id": "9f061dde-51a5-46f8-8e0b-502e119e91f3", + "_postman_id": "302d4e8d-fe62-488f-a80e-cf3520c18277", "name": "taiga-next", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "15018493" + "_exporter_id": "9835734" }, "item": [ { @@ -2348,6 +2348,57 @@ { "name": "workflows", "item": [ + { + "name": "create project workflow", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Post-request execution tasks", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"new workflow\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{protocol}}://{{domain}}{{port}}{{api_url}}/projects/{{pj-id}}/workflows", + "protocol": "{{protocol}}", + "host": [ + "{{domain}}{{port}}{{api_url}}" + ], + "path": [ + "projects", + "{{pj-id}}", + "workflows" + ] + } + }, + "response": [] + }, { "name": "project workflows", "event": [ diff --git a/python/docs/postman/taiga.postman_collection_e2e.json b/python/docs/postman/taiga.postman_collection_e2e.json index ef4f0e5f3..4a5efb9ba 100644 --- a/python/docs/postman/taiga.postman_collection_e2e.json +++ b/python/docs/postman/taiga.postman_collection_e2e.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "cc214ac7-14dc-42ad-b1c6-2beac900fe10", + "_postman_id": "48b40463-3f63-40a6-bfad-432eefa99e17", "name": "taiga-next e2e", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", "_exporter_id": "9835734" @@ -2293,6 +2293,7 @@ " pm.expect(jsonRes).to.have.property(\"id\");", " pm.expect(jsonRes).to.have.property(\"slug\");", " pm.expect(jsonRes).to.have.property(\"workspace\");", + " pm.expect(jsonRes).to.have.property(\"workflows\");", " pm.expect(jsonRes).to.have.property(\"userIsAdmin\");", " pm.expect(jsonRes).to.have.property(\"userIsMember\");", " pm.expect(jsonRes).to.have.property(\"userPermissions\");", @@ -2309,11 +2310,15 @@ "", " pm.expect(workspace.id).to.be.eql(pm.environment.get(\"ws-id\"));", "", + " var workflows = jsonRes.workflows;", + " pm.expect(workflows[0]).to.have.property(\"name\");", + " pm.expect(workflows[0]).to.have.property(\"slug\");", + "", " // Validate we're not returning more fields than expected", " var numOfReturnedFields = Object.keys(workspace).length;", " pm.expect(numOfReturnedFields).to.equal(4);", " numOfReturnedFields = Object.keys(jsonRes).length;", - " pm.expect(numOfReturnedFields).to.equal(13);", + " pm.expect(numOfReturnedFields).to.equal(14);", "});" ], "type": "text/javascript" @@ -2499,6 +2504,7 @@ " pm.expect(jsonRes).to.have.property(\"slug\");", " pm.expect(jsonRes).to.have.property(\"description\");", " pm.expect(jsonRes).to.have.property(\"workspace\");", + " pm.expect(jsonRes).to.have.property(\"workflows\");", " pm.expect(jsonRes).to.have.property(\"userIsAdmin\");", " pm.expect(jsonRes).to.have.property(\"userIsMember\");", " pm.expect(jsonRes).to.have.property(\"userPermissions\");", @@ -2510,11 +2516,15 @@ " pm.expect(workspace).to.have.property(\"slug\");", " pm.expect(workspace).to.have.property(\"userRole\");", "", + " var workflows = jsonRes.workflows;", + " pm.expect(workflows[0]).to.have.property(\"name\");", + " pm.expect(workflows[0]).to.have.property(\"slug\");", + "", " // Validate we're not returning more fields than expected", " var numOfReturnedFields = Object.keys(workspace).length;", " pm.expect(numOfReturnedFields).to.equal(4);", " numOfReturnedFields = Object.keys(jsonRes).length;", - " pm.expect(numOfReturnedFields).to.equal(13);", + " pm.expect(numOfReturnedFields).to.equal(14);", "});" ], "type": "text/javascript" @@ -3319,6 +3329,83 @@ }, "response": [] }, + { + "name": "200.projects.{pj}.workflows", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Tests", + "pm.test(\"HTTP status code is correct\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response JSON is correct\", function () {", + " var jsonRes = pm.response.json();", + "", + " // Validate response API contract", + " pm.expect(jsonRes).to.have.property(\"id\");", + " pm.expect(jsonRes).to.have.property(\"name\");", + " pm.expect(jsonRes).to.have.property(\"slug\");", + " pm.expect(jsonRes).to.have.property(\"order\");", + " pm.expect(jsonRes).to.have.property(\"statuses\");", + " // Validate we're not returning more fields than expected", + " var numOfReturnedFields = Object.keys(jsonRes).length;", + " pm.expect(numOfReturnedFields).to.equal(5);", + "", + " // Validate second-level entries", + " pm.expect(jsonRes.statuses[0]).to.have.property(\"name\");", + " pm.expect(jsonRes.statuses[0]).to.have.property(\"id\");", + " pm.expect(jsonRes.statuses[0]).to.have.property(\"color\");", + " var numOfReturnedFields = Object.keys(jsonRes.statuses[0]).length;", + " pm.expect(numOfReturnedFields).to.equal(4);", + "});" + ], + "type": "text/javascript" + } + } + ], + "protocolProfileBehavior": { + "disabledSystemHeaders": {} + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{auth_token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"new workflow\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{protocol}}://{{domain}}{{port}}{{api_url}}/projects/{{pj-id}}/workflows", + "protocol": "{{protocol}}", + "host": [ + "{{domain}}{{port}}{{api_url}}" + ], + "path": [ + "projects", + "{{pj-id}}", + "workflows" + ] + } + }, + "response": [] + }, { "name": "200.projects.{pj}.workflows", "event": [ @@ -6524,6 +6611,7 @@ " pm.expect(jsonRes).to.have.property(\"id\");", " pm.expect(jsonRes).to.have.property(\"slug\");", " pm.expect(jsonRes).to.have.property(\"workspace\");", + " pm.expect(jsonRes).to.have.property(\"workflows\");", " pm.expect(jsonRes).to.have.property(\"userIsAdmin\");", " pm.expect(jsonRes).to.have.property(\"userIsMember\");", " pm.expect(jsonRes).to.have.property(\"userPermissions\");", @@ -6540,11 +6628,15 @@ "", " pm.expect(workspace.id).to.be.eql(pm.environment.get(\"ws-id\"));", "", + " var workflows = jsonRes.workflows;", + " pm.expect(workflows[0]).to.have.property(\"name\");", + " pm.expect(workflows[0]).to.have.property(\"slug\");", + "", " // Validate we're not returning more fields than expected", " var numOfReturnedFields = Object.keys(workspace).length;", " pm.expect(numOfReturnedFields).to.equal(4);", " numOfReturnedFields = Object.keys(jsonRes).length;", - " pm.expect(numOfReturnedFields).to.equal(13);", + " pm.expect(numOfReturnedFields).to.equal(14);", "});" ], "type": "text/javascript" @@ -7988,6 +8080,7 @@ " pm.expect(jsonRes).to.have.property(\"id\");", " pm.expect(jsonRes).to.have.property(\"slug\");", " pm.expect(jsonRes).to.have.property(\"workspace\");", + " pm.expect(jsonRes).to.have.property(\"workflows\");", " pm.expect(jsonRes).to.have.property(\"userIsAdmin\");", " pm.expect(jsonRes).to.have.property(\"userIsMember\");", " pm.expect(jsonRes).to.have.property(\"userPermissions\");", @@ -8004,11 +8097,15 @@ "", " pm.expect(workspace.id).to.be.eql(pm.environment.get(\"ws-id\"));", "", + " var workflows = jsonRes.workflows;", + " pm.expect(workflows[0]).to.have.property(\"name\");", + " pm.expect(workflows[0]).to.have.property(\"slug\");", + "", " // Validate we're not returning more fields than expected", " var numOfReturnedFields = Object.keys(workspace).length;", " pm.expect(numOfReturnedFields).to.equal(4);", " numOfReturnedFields = Object.keys(jsonRes).length;", - " pm.expect(numOfReturnedFields).to.equal(13);", + " pm.expect(numOfReturnedFields).to.equal(14);", "});" ], "type": "text/javascript"