diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index abc42cb4b..e2d474901 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -38,3 +38,5 @@ from .issue import ( IssueFlatSerializer, IssueStateSerializer, ) + +from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer \ No newline at end of file diff --git a/apiserver/plane/api/serializers/module.py b/apiserver/plane/api/serializers/module.py new file mode 100644 index 000000000..e847c581a --- /dev/null +++ b/apiserver/plane/api/serializers/module.py @@ -0,0 +1,136 @@ +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from .project import ProjectSerializer +from .issue import IssueFlatSerializer + +from plane.db.models import User, Module, ModuleMember, ModuleIssue + + +class ModuleWriteSerializer(BaseSerializer): + + members_list = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def create(self, validated_data): + + members = validated_data.pop("members_list", None) + + project = self.context["project"] + + module = Module.objects.create(**validated_data, project=project) + + if members is not None: + ModuleMember.objects.bulk_create( + [ + ModuleMember( + module=module, + member=member, + project=project, + workspace=project.workspace, + created_by=module.created_by, + updated_by=module.updated_by, + ) + for member in members + ], + batch_size=10, + ) + + return module + + def update(self, instance, validated_data): + + members = validated_data.pop("members_list", None) + + project = self.context["project"] + + module = Module.objects.create(**validated_data, project=project) + + if members is not None: + ModuleMember.objects.bulk_create( + [ + ModuleMember( + module=module, + member=member, + project=project, + workspace=project.workspace, + created_by=module.created_by, + updated_by=module.updated_by, + ) + for member in members + ], + batch_size=10, + ) + + return super().update(instance, validated_data) + + +class ModuleFlatSerializer(BaseSerializer): + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class ModuleIssueSerializer(BaseSerializer): + + module_detail = ModuleFlatSerializer(read_only=True, source="module") + issue_detail = IssueFlatSerializer(read_only=True, source="issue") + + class Meta: + model = ModuleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "module", + ] + + +class ModuleSerializer(BaseSerializer): + + project_detail = ProjectSerializer(read_only=True, source="project") + lead_detail = UserLiteSerializer(read_only=True, source="lead") + members_detail = UserLiteSerializer(read_only=True, many=True, source="members") + module_issues = ModuleIssueSerializer(read_only=True, many=True) + + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 149dc3329..ef53b4519 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -54,6 +54,8 @@ from plane.api.views import ( BulkDeleteIssuesEndpoint, BulkAssignIssuesToCycleEndpoint, ProjectUserViewsEndpoint, + ModuleViewSet, + ModuleIssueViewSet, UserLastProjectWithWorkspaceEndpoint, UserWorkSpaceIssues, ) @@ -587,6 +589,52 @@ urlpatterns = [ name="File Assets", ), ## End File Assets + ## Modules + path( + "workspaces//projects//modules/", + ModuleViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-modules", + ), + path( + "workspaces//projects//modules//", + ModuleViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-modules", + ), + path( + "workspaces//projects//modules//module-issues/", + ModuleIssueViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-module-issues", + ), + path( + "workspaces//projects//modules//module-issues//", + ModuleIssueViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-module-issues", + ), + ## End Modules # path( # "issues//all/", # IssueViewSet.as_view({"get": "list_issue_history_comments"}), diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index a4d9021c6..b55342f87 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -66,3 +66,5 @@ from .authentication import ( MagicSignInEndpoint, MagicSignInGenerateEndpoint, ) + +from .module import ModuleViewSet, ModuleIssueViewSet diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py new file mode 100644 index 000000000..4ff914890 --- /dev/null +++ b/apiserver/plane/api/views/module.py @@ -0,0 +1,109 @@ +# Django Imports +from django.db import IntegrityError +from django.db.models import Prefetch + +# Third party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from . import BaseViewSet +from plane.api.serializers import ( + ModuleWriteSerializer, + ModuleSerializer, + ModuleIssueSerializer, +) +from plane.api.permissions import ProjectEntityPermission +from plane.db.models import Module, ModuleIssue, Project + + +class ModuleViewSet(BaseViewSet): + + model = Module + permission_classes = [ + ProjectEntityPermission, + ] + + def get_serializer_class(self): + return ( + ModuleWriteSerializer + if self.action in ["create", "update", "partial_update"] + else ModuleSerializer + ) + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("project") + .select_related("workspace") + .select_related("lead") + .prefetch_related("members") + .prefetch_related( + Prefetch( + "module_issues", + queryset=ModuleIssue.objects.select_related("module", "issue"), + ) + ) + ) + + def create(self, request, slug, project_id): + try: + project = Project.objects.get(workspace__slug=slug, pk=project_id) + serializer = ModuleWriteSerializer( + data=request.data, context={"project": project} + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + except Project.DoesNotExist: + return Response( + {"error": "Project was not found"}, status=status.HTTP_404_NOT_FOUND + ) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The module name is already taken"}, + status=status.HTTP_410_GONE, + ) + + +class ModuleIssueViewSet(BaseViewSet): + + serializer_class = ModuleIssueSerializer + model = ModuleIssue + + filterset_fields = [ + "issue__id", + "workspace__id", + ] + + permission_classes = [ + ProjectEntityPermission, + ] + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + module_id=self.kwargs.get("module_id"), + ) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(module_id=self.kwargs.get("module_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .select_related("module") + .select_related("issue") + .distinct() + ) diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 0e3fdfafa..38091aa86 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -36,3 +36,5 @@ from .cycle import Cycle, CycleIssue from .shortcut import Shortcut from .view import View + +from .module import Module, ModuleMember, ModuleIssue diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py new file mode 100644 index 000000000..6ce04d4cb --- /dev/null +++ b/apiserver/plane/db/models/module.py @@ -0,0 +1,88 @@ +# Django imports +from django.db import models +from django.conf import settings + +# Module imports +from . import ProjectBaseModel + + +class Module(ProjectBaseModel): + + name = models.CharField(max_length=255, verbose_name="Module Name") + description = models.TextField(verbose_name="Module Description", blank=True) + description_text = models.JSONField( + verbose_name="Module Description RT", blank=True, null=True + ) + description_html = models.JSONField( + verbose_name="Module Description HTML", blank=True, null=True + ) + start_date = models.DateField(null=True) + target_date = models.DateField(null=True) + status = models.CharField( + choices=( + ("backlog", "Backlog"), + ("planned", "Planned"), + ("in-progress", "In Progress"), + ("paused", "Paused"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ), + default="planned", + max_length=20, + ) + lead = models.ForeignKey( + "db.User", on_delete=models.SET_NULL, related_name="module_leads", null=True + ) + members = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="module_members", + through="ModuleMember", + through_fields=("module", "member"), + ) + + class Meta: + unique_together = ["name", "project"] + verbose_name = "Module" + verbose_name_plural = "Modules" + db_table = "module" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.name} {self.start_date} {self.target_date}" + + +class ModuleMember(ProjectBaseModel): + + module = models.ForeignKey("db.Module", on_delete=models.CASCADE) + member = models.ForeignKey("db.User", on_delete=models.CASCADE) + + class Meta: + unique_together = ["module", "member"] + verbose_name = "Module Member" + verbose_name_plural = "Module Members" + db_table = "module_member" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.module.name} {self.member}" + + +class ModuleIssue(ProjectBaseModel): + + module = models.ForeignKey( + "db.Module", on_delete=models.CASCADE, related_name="module_issues" + ) + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, related_name="module_issues" + ) + + class Meta: + unique_together = ["module", "issue"] + verbose_name = "Module Issue" + verbose_name_plural = "Module Issues" + db_table = "module_issues" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.module.name} {self.issue.name}"