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/issue.py b/apiserver/plane/api/serializers/issue.py index 6315564ce..b2dca6625 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -7,6 +7,7 @@ from .user import UserLiteSerializer from .state import StateSerializer from .user import UserLiteSerializer from .project import ProjectSerializer +from .workspace import WorkSpaceSerializer from plane.db.models import ( User, Issue, @@ -19,8 +20,8 @@ from plane.db.models import ( IssueLabel, Label, IssueBlocker, - Cycle, CycleIssue, + Cycle, ) @@ -54,6 +55,9 @@ class IssueStateSerializer(BaseSerializer): class IssueCreateSerializer(BaseSerializer): state_detail = StateSerializer(read_only=True, source="state") + created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + project_detail = ProjectSerializer(read_only=True, source="project") + workspace_detail = WorkSpaceSerializer(read_only=True, source="workspace") assignees_list = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), @@ -213,6 +217,8 @@ class IssueActivitySerializer(BaseSerializer): class IssueCommentSerializer(BaseSerializer): actor_detail = UserLiteSerializer(read_only=True, source="actor") + issue_detail = IssueFlatSerializer(read_only=True, source="issue") + project_detail = ProjectSerializer(read_only=True, source="project") class Meta: model = IssueComment @@ -305,7 +311,6 @@ class IssueAssigneeSerializer(BaseSerializer): class CycleBaseSerializer(BaseSerializer): - class Meta: model = Cycle fields = "__all__" @@ -318,6 +323,7 @@ class CycleBaseSerializer(BaseSerializer): "updated_at", ] + class IssueCycleDetailSerializer(BaseSerializer): cycle_detail = CycleBaseSerializer(read_only=True, source="cycle") @@ -335,7 +341,6 @@ class IssueCycleDetailSerializer(BaseSerializer): ] - class IssueSerializer(BaseSerializer): project_detail = ProjectSerializer(read_only=True, source="project") state_detail = StateSerializer(read_only=True, source="state") 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/authentication.py b/apiserver/plane/api/views/authentication.py index b1d321f9c..ca8e2df60 100644 --- a/apiserver/plane/api/views/authentication.py +++ b/apiserver/plane/api/views/authentication.py @@ -34,6 +34,74 @@ def get_tokens_for_user(user): ) +class SignUpEndpoint(BaseAPIView): + + permission_classes = (AllowAny,) + + def post(self, request): + try: + + email = request.data.get("email", False) + password = request.data.get("password", False) + + ## Raise exception if any of the above are missing + if not email or not password: + return Response( + {"error": "Both email and password are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + email = email.strip().lower() + + try: + validate_email(email) + except ValidationError as e: + return Response( + {"error": "Please provide a valid email address."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.filter(email=email).first() + + if user is not None: + return Response( + {"error": "Email ID is already taken"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.create(email=email) + user.set_password(password) + + # settings last actives for the user + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() + + serialized_user = UserSerializer(user).data + + access_token, refresh_token = get_tokens_for_user(user) + + data = { + "access_token": access_token, + "refresh_token": refresh_token, + "user": serialized_user, + } + + return Response(data, status=status.HTTP_200_OK) + + except Exception as e: + capture_exception(e) + return Response( + { + "error": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + class SignInEndpoint(BaseAPIView): permission_classes = (AllowAny,) @@ -104,7 +172,6 @@ class SignInEndpoint(BaseAPIView): status=status.HTTP_403_FORBIDDEN, ) except Exception as e: - print(e) capture_exception(e) return Response( { @@ -218,7 +285,6 @@ class MagicSignInGenerateEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) except Exception as e: - print(e) capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, 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/migrations/0011_auto_20221216_0259.py b/apiserver/plane/db/migrations/0011_auto_20221216_0259.py new file mode 100644 index 000000000..3ea3b9d0b --- /dev/null +++ b/apiserver/plane/db/migrations/0011_auto_20221216_0259.py @@ -0,0 +1,110 @@ +# Generated by Django 3.2.14 on 2022-12-15 21:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0010_auto_20221213_2348'), + ] + + operations = [ + migrations.CreateModel( + name='Module', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255, verbose_name='Module Name')), + ('description', models.TextField(blank=True, verbose_name='Module Description')), + ('description_text', models.JSONField(blank=True, null=True, verbose_name='Module Description RT')), + ('description_html', models.JSONField(blank=True, null=True, verbose_name='Module Description HTML')), + ('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)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='module_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('lead', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='module_leads', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Module', + 'verbose_name_plural': 'Modules', + 'db_table': 'module', + 'ordering': ('-created_at',), + }, + ), + migrations.AddField( + model_name='project', + name='icon', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.CreateModel( + name='ModuleMember', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulemember_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='db.module')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_modulemember', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulemember_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_modulemember', to='db.workspace')), + ], + options={ + 'verbose_name': 'Module Member', + 'verbose_name_plural': 'Module Members', + 'db_table': 'module_member', + 'ordering': ('-created_at',), + 'unique_together': {('module', 'member')}, + }, + ), + migrations.AddField( + model_name='module', + name='members', + field=models.ManyToManyField(blank=True, related_name='module_members', through='db.ModuleMember', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='module', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_module', to='db.project'), + ), + migrations.AddField( + model_name='module', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='module_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AddField( + model_name='module', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_module', to='db.workspace'), + ), + migrations.CreateModel( + name='ModuleIssue', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='moduleissue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='module_issues', to='db.issue')), + ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='module_issues', to='db.module')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_moduleissue', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='moduleissue_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_moduleissue', to='db.workspace')), + ], + options={ + 'verbose_name': 'Module Issue', + 'verbose_name_plural': 'Module Issues', + 'db_table': 'module_issues', + 'ordering': ('-created_at',), + 'unique_together': {('module', 'issue')}, + }, + ), + migrations.AlterUniqueTogether( + name='module', + unique_together={('name', 'project')}, + ), + ] 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}" diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 1bed4fc8f..d8e46869f 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -53,6 +53,7 @@ class Project(BaseModel): null=True, blank=True, ) + icon = models.CharField(max_length=255, null=True, blank=True) def __str__(self): """Return name of the project"""