-
Notifications
You must be signed in to change notification settings - Fork 5k
feat(template): implement work item template CRUD and instantiation #9391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
gouthamx67
wants to merge
3
commits into
makeplane:preview
Choose a base branch
from
gouthamx67:feat/template-architectural-review
base: preview
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,323 @@ | ||
| # Copyright (c) 2023-present Plane Software, Inc. and contributors | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
| # See the LICENSE file for details. | ||
|
|
||
| # Python imports | ||
| from collections import defaultdict | ||
|
|
||
| # Third Party imports | ||
| from rest_framework import serializers | ||
|
|
||
| # Module imports | ||
| from .base import BaseSerializer | ||
| from plane.db.models import WorkItemTemplate, WorkItemTemplateItem, WorkItemTemplateDependency | ||
| from plane.db.models.issue import IssueRelationChoices | ||
|
|
||
|
|
||
| VALID_RELATION_TYPES = {choice.value for choice in IssueRelationChoices} | ||
|
|
||
|
|
||
| class TemplateItemIdField(serializers.Field): | ||
| """Accepts any string on write; outputs the PK (UUID) on read.""" | ||
|
|
||
| def to_representation(self, value): | ||
| if hasattr(value, "pk"): | ||
| return str(value.pk) | ||
| return str(value) | ||
|
|
||
| def to_internal_value(self, data): | ||
| return str(data) | ||
|
|
||
|
|
||
| class WorkItemTemplateDependencySerializer(BaseSerializer): | ||
| source_template_item = TemplateItemIdField() | ||
| target_template_item = TemplateItemIdField() | ||
|
|
||
| def validate_relation_type(self, value): | ||
| if value not in VALID_RELATION_TYPES: | ||
| raise serializers.ValidationError( | ||
| f"Invalid relation type '{value}'. Must be one of: {', '.join(sorted(VALID_RELATION_TYPES))}." | ||
| ) | ||
| return value | ||
|
|
||
| class Meta: | ||
| model = WorkItemTemplateDependency | ||
| fields = [ | ||
| "id", | ||
| "template", | ||
| "source_template_item", | ||
| "target_template_item", | ||
| "relation_type", | ||
| "created_at", | ||
| "updated_at", | ||
| ] | ||
| read_only_fields = [ | ||
| "workspace", | ||
| "project", | ||
| "template", | ||
| "created_by", | ||
| "created_at", | ||
| "updated_by", | ||
| "updated_at", | ||
| ] | ||
|
|
||
|
|
||
| class WorkItemTemplateItemSerializer(BaseSerializer): | ||
| id = TemplateItemIdField(required=False) | ||
|
|
||
| class Meta: | ||
| model = WorkItemTemplateItem | ||
| fields = [ | ||
| "id", | ||
| "template", | ||
| "name", | ||
| "description", | ||
| "priority", | ||
| "type", | ||
| "sort_order", | ||
| "created_at", | ||
| "updated_at", | ||
| ] | ||
| read_only_fields = [ | ||
| "workspace", | ||
| "project", | ||
| "template", | ||
| "created_by", | ||
| "created_at", | ||
| "updated_by", | ||
| "updated_at", | ||
| ] | ||
|
|
||
|
|
||
| class WorkItemTemplateSerializer(BaseSerializer): | ||
| items = WorkItemTemplateItemSerializer(many=True, read_only=True) | ||
| dependencies = WorkItemTemplateDependencySerializer(many=True, read_only=True) | ||
|
|
||
| class Meta: | ||
| model = WorkItemTemplate | ||
| fields = [ | ||
| "id", | ||
| "name", | ||
| "description", | ||
| "type", | ||
| "priority", | ||
| "items", | ||
| "dependencies", | ||
| "project", | ||
| "workspace", | ||
| "created_at", | ||
| "updated_at", | ||
| ] | ||
| read_only_fields = [ | ||
| "workspace", | ||
| "project", | ||
| "created_by", | ||
| "created_at", | ||
| "updated_by", | ||
| "updated_at", | ||
| ] | ||
|
|
||
|
|
||
| class WorkItemTemplateCreateSerializer(BaseSerializer): | ||
| items = WorkItemTemplateItemSerializer(many=True, required=False) | ||
| dependencies = WorkItemTemplateDependencySerializer(many=True, required=False) | ||
|
|
||
| class Meta: | ||
| model = WorkItemTemplate | ||
| fields = [ | ||
| "id", | ||
| "name", | ||
| "description", | ||
| "type", | ||
| "priority", | ||
| "items", | ||
| "dependencies", | ||
| "created_at", | ||
| "updated_at", | ||
| ] | ||
| read_only_fields = [ | ||
| "workspace", | ||
| "project", | ||
| "created_by", | ||
| "created_at", | ||
| "updated_by", | ||
| "updated_at", | ||
| ] | ||
|
|
||
| def _get_item_ids(self, items_data): | ||
| ids = set() | ||
| for item_data in items_data: | ||
| item_id = item_data.get("id") | ||
| if item_id is not None: | ||
| ids.add(str(item_id)) | ||
| return ids | ||
|
|
||
| def _validate_dependencies(self, item_ids, dependencies_data): | ||
| seen = set() | ||
| for i, dep in enumerate(dependencies_data): | ||
| source_id = str(dep.get("source_template_item", "")) | ||
| target_id = str(dep.get("target_template_item", "")) | ||
| relation_type = dep.get("relation_type", "blocked_by") | ||
|
|
||
| if not source_id or not target_id: | ||
| raise serializers.ValidationError( | ||
| {f"dependencies[{i}]": "Both source_template_item and target_template_item are required."} | ||
| ) | ||
|
|
||
| if source_id == target_id: | ||
| raise serializers.ValidationError( | ||
| {f"dependencies[{i}]": "A template item cannot depend on itself."} | ||
| ) | ||
|
|
||
| if source_id not in item_ids: | ||
| raise serializers.ValidationError( | ||
| {f"dependencies[{i}].source_template_item": f"Template item {source_id} was not found in the items list."} | ||
| ) | ||
|
|
||
| if target_id not in item_ids: | ||
| raise serializers.ValidationError( | ||
| {f"dependencies[{i}].target_template_item": f"Template item {target_id} was not found in the items list."} | ||
| ) | ||
|
|
||
| dep_key = (source_id, target_id, relation_type) | ||
| if dep_key in seen: | ||
| raise serializers.ValidationError( | ||
| {f"dependencies[{i}]": "Duplicate dependency definition."} | ||
| ) | ||
| seen.add(dep_key) | ||
|
|
||
| def _detect_cycles(self, items_data, dependencies_data): | ||
| children = defaultdict(list) | ||
| item_id_set = {str(d.get("id")) for d in items_data if d.get("id")} | ||
| for dep in dependencies_data: | ||
| source = str(dep.get("source_template_item", "")) | ||
| target = str(dep.get("target_template_item", "")) | ||
| if source and target: | ||
| children[source].append(target) | ||
|
|
||
| visited = set() | ||
| in_stack = set() | ||
|
|
||
| def dfs(node_id): | ||
| if node_id in in_stack: | ||
| return True | ||
| if node_id in visited: | ||
| return False | ||
| visited.add(node_id) | ||
| in_stack.add(node_id) | ||
| for neighbor in children.get(node_id, []): | ||
| if dfs(neighbor): | ||
| return True | ||
| in_stack.remove(node_id) | ||
| return False | ||
|
|
||
| for node_id in list(item_id_set): | ||
| visited.clear() | ||
| in_stack.clear() | ||
| if dfs(node_id): | ||
| raise serializers.ValidationError( | ||
| {"dependencies": "Circular dependency detected. Dependencies must form a directed acyclic graph."} | ||
| ) | ||
|
|
||
| def validate(self, attrs): | ||
| items_data = attrs.get("items", []) | ||
| dependencies_data = attrs.get("dependencies", []) | ||
|
|
||
| if dependencies_data: | ||
| if not items_data: | ||
| raise serializers.ValidationError( | ||
| {"dependencies": "Cannot define dependencies without template items."} | ||
| ) | ||
| item_ids = self._get_item_ids(items_data) | ||
| self._validate_dependencies(item_ids, dependencies_data) | ||
| self._detect_cycles(items_data, dependencies_data) | ||
|
|
||
| return attrs | ||
|
|
||
| def create(self, validated_data): | ||
| items_data = validated_data.pop("items", []) | ||
| dependencies_data = validated_data.pop("dependencies", []) | ||
| template = WorkItemTemplate.objects.create(**validated_data) | ||
|
|
||
| item_map = {} | ||
| for item_data in items_data: | ||
| temp_id = item_data.pop("id", None) | ||
| item = WorkItemTemplateItem.objects.create( | ||
| template=template, | ||
| project_id=template.project_id, | ||
| workspace_id=template.workspace_id, | ||
| **item_data, | ||
| ) | ||
| key = str(temp_id) if temp_id else str(item.id) | ||
| item_map[key] = item | ||
|
|
||
| for dep_data in dependencies_data: | ||
| source_id = dep_data.pop("source_template_item") | ||
| target_id = dep_data.pop("target_template_item") | ||
| source_item = item_map[source_id] | ||
| target_item = item_map[target_id] | ||
| WorkItemTemplateDependency.objects.create( | ||
| template=template, | ||
| project_id=template.project_id, | ||
| workspace_id=template.workspace_id, | ||
| source_template_item=source_item, | ||
| target_template_item=target_item, | ||
| **dep_data, | ||
| ) | ||
|
|
||
| return template | ||
|
|
||
| def update(self, instance, validated_data): | ||
| items_data = validated_data.pop("items", None) | ||
| dependencies_data = validated_data.pop("dependencies", None) | ||
|
|
||
| for attr, value in validated_data.items(): | ||
| setattr(instance, attr, value) | ||
| instance.save() | ||
|
|
||
| if items_data is not None: | ||
| existing_items = {str(item.id): item for item in instance.items.all()} | ||
| incoming_ids = set() | ||
| item_map = {} | ||
| for item_data in items_data: | ||
| temp_id = item_data.pop("id", None) | ||
| if temp_id and str(temp_id) in existing_items: | ||
| item = existing_items[str(temp_id)] | ||
| for attr, value in item_data.items(): | ||
| setattr(item, attr, value) | ||
| item.save() | ||
| incoming_ids.add(str(temp_id)) | ||
| item_map[str(temp_id)] = item | ||
| else: | ||
| new_item = WorkItemTemplateItem.objects.create( | ||
| template=instance, | ||
| project_id=instance.project_id, | ||
| workspace_id=instance.workspace_id, | ||
| **item_data, | ||
| ) | ||
| incoming_ids.add(str(new_item.id)) | ||
| key = str(temp_id) if temp_id else str(new_item.id) | ||
| item_map[key] = new_item | ||
|
|
||
| for item_id, item in existing_items.items(): | ||
| if item_id not in incoming_ids: | ||
| item.delete() | ||
| item_map.pop(item_id, None) | ||
|
|
||
| if dependencies_data is not None: | ||
| instance.dependencies.all().delete() | ||
| for dep_data in dependencies_data: | ||
| source_id = dep_data.pop("source_template_item") | ||
| target_id = dep_data.pop("target_template_item") | ||
| source_item = item_map[source_id] | ||
| target_item = item_map[target_id] | ||
| WorkItemTemplateDependency.objects.create( | ||
| template=instance, | ||
| project_id=instance.project_id, | ||
| workspace_id=instance.workspace_id, | ||
| source_template_item=source_item, | ||
| target_template_item=target_item, | ||
| **dep_data, | ||
| ) | ||
|
|
||
| return instance | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| # Copyright (c) 2023-present Plane Software, Inc. and contributors | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
| # See the LICENSE file for details. | ||
|
|
||
| # Django imports | ||
| from django.urls import path | ||
|
|
||
| # Module imports | ||
| from plane.app.views import WorkItemTemplateViewSet | ||
|
|
||
|
|
||
| urlpatterns = [ | ||
| path( | ||
| "workspaces/<str:slug>/projects/<uuid:project_id>/work-item-templates/", | ||
| WorkItemTemplateViewSet.as_view({"get": "list", "post": "create"}), | ||
| name="project-work-item-templates", | ||
| ), | ||
| path( | ||
| "workspaces/<str:slug>/projects/<uuid:project_id>/work-item-templates/<uuid:pk>/", | ||
| WorkItemTemplateViewSet.as_view( | ||
| { | ||
| "get": "retrieve", | ||
| "put": "update", | ||
| "patch": "partial_update", | ||
| "delete": "destroy", | ||
| } | ||
| ), | ||
| name="project-work-item-templates", | ||
| ), | ||
| path( | ||
| "workspaces/<str:slug>/projects/<uuid:project_id>/work-item-templates/<uuid:pk>/instantiate/", | ||
| WorkItemTemplateViewSet.as_view({"post": "instantiate"}), | ||
| name="project-work-item-templates-instantiate", | ||
| ), | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For payloads that include the same source/target pair twice with different
relation_typevalues, this check treats them as distinct even thoughWorkItemTemplateDependencyis unique only on template/source/target. The serializer then starts creating the template/items/dependencies and hits anIntegrityErrorpartway through, so the API can return an error after leaving partial template data behind; the duplicate key should match the database uniqueness or the whole save should be atomic.Useful? React with 👍 / 👎.