diff --git a/python/apps/taiga/src/taiga/conf/__init__.py b/python/apps/taiga/src/taiga/conf/__init__.py index e37cef96c..15013bcb5 100644 --- a/python/apps/taiga/src/taiga/conf/__init__.py +++ b/python/apps/taiga/src/taiga/conf/__init__.py @@ -87,6 +87,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..2733b0f5c 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 @@ -36,15 +36,14 @@ "workflows": [ { "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} - ] + "name": "Main" } + ], + "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..449b4e503 --- /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-06 13:34 + +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..fcfc6e870 100644 --- a/python/apps/taiga/src/taiga/projects/projects/models.py +++ b/python/apps/taiga/src/taiga/projects/projects/models.py @@ -70,6 +70,10 @@ def __repr__(self) -> str: def slug(self) -> str: return slugify(self.name) + @property + def num_workflows(self) -> int: + return len(self.workflows.all()) + @property def public_user_can_view(self) -> bool: """ @@ -99,6 +103,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..b72c0474f 100644 --- a/python/apps/taiga/src/taiga/projects/projects/repositories.py +++ b/python/apps/taiga/src/taiga/projects/projects/repositories.py @@ -71,7 +71,7 @@ def _apply_filters_to_project_queryset( return qs.filter(**filter_data) -ProjectSelectRelated = list[Literal["workspace",]] +ProjectSelectRelated = list[Literal["workspace"]] def _apply_select_related_to_project_queryset( @@ -81,7 +81,7 @@ def _apply_select_related_to_project_queryset( return qs.select_related(*select_related) -ProjectPrefetchRelated = list[Literal["workspace",]] +ProjectPrefetchRelated = list[Literal["workspace", "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 = ["workspace", "workflows"], ) -> 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) @@ -275,10 +275,9 @@ def apply_template_to_project_sync(template: ProjectTemplate, project: Project) wf = workflows_repositories.create_workflow_sync( name=workflow["name"], slug=workflow["slug"], - 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/services/__init__.py b/python/apps/taiga/src/taiga/projects/projects/services/__init__.py index 4c709c864..80ec21008 100644 --- a/python/apps/taiga/src/taiga/projects/projects/services/__init__.py +++ b/python/apps/taiga/src/taiga/projects/projects/services/__init__.py @@ -134,8 +134,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"] ) diff --git a/python/apps/taiga/src/taiga/workflows/api/__init__.py b/python/apps/taiga/src/taiga/workflows/api/__init__.py index 32e1db5ae..57475bb8e 100644 --- a/python/apps/taiga/src/taiga/workflows/api/__init__.py +++ b/python/apps/taiga/src/taiga/workflows/api/__init__.py @@ -22,11 +22,13 @@ ReorderWorkflowStatusesValidator, UpdateWorkflowStatusValidator, WorkflowStatusValidator, + WorkflowValidator, ) 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: WorkflowValidator, +) -> 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) ################################################ diff --git a/python/apps/taiga/src/taiga/workflows/api/validators.py b/python/apps/taiga/src/taiga/workflows/api/validators.py index ca50f9f32..5b610f123 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 WorkflowName(ConstrainedStr): + strip_whitespace = True + min_length = 1 + max_length = 40 + + +class WorkflowValidator(BaseModel): + name: WorkflowName + + class WorkflowStatusValidator(BaseModel): - name: Name + 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/migrations/0004_alter_workflow_options_and_more.py b/python/apps/taiga/src/taiga/workflows/migrations/0004_alter_workflow_options_and_more.py new file mode 100644 index 000000000..f2d543c93 --- /dev/null +++ b/python/apps/taiga/src/taiga/workflows/migrations/0004_alter_workflow_options_and_more.py @@ -0,0 +1,41 @@ +# -*- 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-05 13:25 + +import taiga.base.utils.datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("workflows", "0003_remove_workflowstatus_workflows_workflowstatus_unique_workflow_slug_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="workflow", + options={ + "ordering": ["project", "created_at"], + "verbose_name": "workflow", + "verbose_name_plural": "workflows", + }, + ), + migrations.RemoveConstraint( + model_name="workflow", + name="workflows_workflow_unique_project_name", + ), + migrations.RemoveField( + model_name="workflow", + name="order", + ), + migrations.AddField( + model_name="workflow", + name="created_at", + field=models.DateTimeField(default=taiga.base.utils.datetime.aware_utcnow, verbose_name="created at"), + ), + ] diff --git a/python/apps/taiga/src/taiga/workflows/models.py b/python/apps/taiga/src/taiga/workflows/models.py index 1236ea924..fc188ebc9 100644 --- a/python/apps/taiga/src/taiga/workflows/models.py +++ b/python/apps/taiga/src/taiga/workflows/models.py @@ -5,15 +5,17 @@ # # 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.db.mixins import CreatedAtMetaInfoMixin +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): +class Workflow(models.BaseModel, CreatedAtMetaInfoMixin): 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") project = models.ForeignKey( "projects.Project", null=False, @@ -28,12 +30,11 @@ 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"]), ] - ordering = ["project", "order", "name"] + ordering = ["project", "created_at"] def __str__(self) -> str: return self.name @@ -41,6 +42,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..2150d9f42 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["created_at",]] def _apply_order_by_to_workflow_queryset( @@ -86,18 +86,23 @@ def _apply_order_by_to_workflow_queryset( def create_workflow_sync( name: str, slug: str, - order: int, project: Project, ) -> Workflow: return Workflow.objects.create( name=name, slug=slug, - order=order, project=project, ) -create_workflow = sync_to_async(create_workflow_sync) +async def create_workflow( + project: Project, + name: str, +) -> Workflow: + return await Workflow.objects.acreate( + project=project, + name=name, + ) ########################################################## @@ -109,7 +114,7 @@ def create_workflow_sync( def list_workflows( filters: WorkflowFilters = {}, prefetch_related: WorkflowPrefetchRelated = ["statuses"], - order_by: WorkflowOrderBy = ["order"], + order_by: WorkflowOrderBy = ["created_at"], ) -> list[Workflow]: qs = _apply_filters_to_workflow_queryset(qs=DEFAULT_QUERYSET_WORKFLOW, filters=filters) qs = _apply_prefetch_related_to_workflow_queryset(qs=qs, prefetch_related=prefetch_related) @@ -324,3 +329,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/__init__.py b/python/apps/taiga/src/taiga/workflows/serializers/__init__.py index 1e3b13ad1..7aab0db30 100644 --- a/python/apps/taiga/src/taiga/workflows/serializers/__init__.py +++ b/python/apps/taiga/src/taiga/workflows/serializers/__init__.py @@ -13,7 +13,6 @@ class WorkflowSerializer(BaseModel): id: UUIDB64 name: str slug: str - order: int statuses: list[WorkflowStatusNestedSerializer] class Config: diff --git a/python/apps/taiga/src/taiga/workflows/serializers/services.py b/python/apps/taiga/src/taiga/workflows/serializers/services.py index 9b184f649..f19d66dc8 100644 --- a/python/apps/taiga/src/taiga/workflows/serializers/services.py +++ b/python/apps/taiga/src/taiga/workflows/serializers/services.py @@ -18,7 +18,6 @@ def serialize_workflow(workflow: Workflow, workflow_statuses: list[WorkflowStatu id=workflow.id, name=workflow.name, slug=workflow.slug, - order=workflow.order, statuses=workflow_statuses, ) diff --git a/python/apps/taiga/src/taiga/workflows/services/__init__.py b/python/apps/taiga/src/taiga/workflows/services/__init__.py index bcb8d495b..d06ee341e 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 @@ -23,6 +26,33 @@ 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: + # Validate num workflows + if project.num_workflows >= settings.MAX_NUM_WORKFLOWS: + raise ex.MaxNumWorkflowCreatedError("Maximum number of workflows is reached") + + workflow = await workflows_repositories.create_workflow(project=project, name=name) + + # 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}) + return serializers_services.serialize_workflow(workflow=workflow, workflow_statuses=workflow_statuses) + + ########################################################## # 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/utils/factories/workflows.py b/python/apps/taiga/tests/utils/factories/workflows.py index 368053743..8e92f17c8 100644 --- a/python/apps/taiga/tests/utils/factories/workflows.py +++ b/python/apps/taiga/tests/utils/factories/workflows.py @@ -5,6 +5,8 @@ # # Copyright (c) 2023-present Kaleidos INC from asgiref.sync import sync_to_async +from taiga.projects.projects.models import ProjectTemplate +from taiga.workflows.models import workflows_repositories from .base import Factory, factory @@ -13,7 +15,6 @@ class WorkflowFactory(Factory): name = factory.Sequence(lambda n: f"Workflow {n}") slug = factory.Sequence(lambda n: f"workflow-{n}") order = factory.Sequence(lambda n: n) - statuses = factory.RelatedFactoryList("tests.utils.factories.WorkflowStatusFactory", "workflow", size=3) project = factory.SubFactory("tests.utils.factories.ProjectFactory") class Meta: @@ -37,7 +38,15 @@ def build_workflow(**kwargs): @sync_to_async def create_workflow(**kwargs): - return WorkflowFactory.create(**kwargs) + """Create workflow and its workflow statuses""" + defaults = {} + defaults.update(kwargs) + + workflow = WorkflowFactory.create(**defaults) + template = ProjectTemplate.objects.first() + workflows_repositories.apply_default_workflow_statuses_sync(template=template, workflow=workflow) + + return workflow def build_workflow_status(**kwargs):