From 3a6d3d4e82b05e79c37f80eb53544b902301ac4e Mon Sep 17 00:00:00 2001 From: Henit Chobisa Date: Tue, 23 Jul 2024 19:20:50 +0530 Subject: [PATCH] feat: added external api endpoints for creating users and adding attachments to issues (#5193) * feat: added external id and external source for issue attachments * feat: added endpoint for creating users * feat: added issue attachment endpoint * fix: converted user to workspace member * chore: removed code blocking adding issues when the cycle has been completed * chore: update models * chore: added user recent visited table --------- Co-authored-by: pablohashescobar Co-authored-by: NarayanBavisetti --- apiserver/plane/api/urls/__init__.py | 2 + apiserver/plane/api/urls/issue.py | 6 + apiserver/plane/api/urls/member.py | 13 ++ apiserver/plane/api/views/__init__.py | 3 + apiserver/plane/api/views/cycle.py | 12 -- apiserver/plane/api/views/issue.py | 82 ++++++++++ apiserver/plane/api/views/member.py | 147 ++++++++++++++++++ ...72_issueattachment_external_id_and_more.py | 145 +++++++++++++++++ apiserver/plane/db/models/__init__.py | 2 + apiserver/plane/db/models/issue.py | 8 +- apiserver/plane/db/models/project.py | 6 +- apiserver/plane/db/models/recent_visit.py | 38 +++++ 12 files changed, 444 insertions(+), 20 deletions(-) create mode 100644 apiserver/plane/api/urls/member.py create mode 100644 apiserver/plane/api/views/member.py create mode 100644 apiserver/plane/db/migrations/0072_issueattachment_external_id_and_more.py create mode 100644 apiserver/plane/db/models/recent_visit.py diff --git a/apiserver/plane/api/urls/__init__.py b/apiserver/plane/api/urls/__init__.py index 84927439e2..efa84bce03 100644 --- a/apiserver/plane/api/urls/__init__.py +++ b/apiserver/plane/api/urls/__init__.py @@ -4,6 +4,7 @@ from .issue import urlpatterns as issue_patterns from .cycle import urlpatterns as cycle_patterns from .module import urlpatterns as module_patterns from .inbox import urlpatterns as inbox_patterns +from .member import urlpatterns as member_patterns urlpatterns = [ *project_patterns, @@ -12,4 +13,5 @@ urlpatterns = [ *cycle_patterns, *module_patterns, *inbox_patterns, + *member_patterns, ] diff --git a/apiserver/plane/api/urls/issue.py b/apiserver/plane/api/urls/issue.py index 5ce9db85c7..e9bf030a2a 100644 --- a/apiserver/plane/api/urls/issue.py +++ b/apiserver/plane/api/urls/issue.py @@ -7,6 +7,7 @@ from plane.api.views import ( IssueCommentAPIEndpoint, IssueActivityAPIEndpoint, WorkspaceIssueAPIEndpoint, + IssueAttachmentEndpoint, ) urlpatterns = [ @@ -65,4 +66,9 @@ urlpatterns = [ IssueActivityAPIEndpoint.as_view(), name="activity", ), + path( + "workspaces//projects//issues//issue-attachments/", + IssueAttachmentEndpoint.as_view(), + name="attachment", + ), ] diff --git a/apiserver/plane/api/urls/member.py b/apiserver/plane/api/urls/member.py new file mode 100644 index 0000000000..9a622d35a6 --- /dev/null +++ b/apiserver/plane/api/urls/member.py @@ -0,0 +1,13 @@ +from django.urls import path + +from plane.api.views import ( + WorkspaceMemberAPIEndpoint, +) + +urlpatterns = [ + path( + "workspaces//members/", + WorkspaceMemberAPIEndpoint.as_view(), + name="users", + ), +] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index d59b40fc59..48461cee27 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -9,6 +9,7 @@ from .issue import ( IssueLinkAPIEndpoint, IssueCommentAPIEndpoint, IssueActivityAPIEndpoint, + IssueAttachmentEndpoint, ) from .cycle import ( @@ -24,4 +25,6 @@ from .module import ( ModuleArchiveUnarchiveAPIEndpoint, ) +from .member import WorkspaceMemberAPIEndpoint + from .inbox import InboxIssueAPIEndpoint diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 8b9f90de5f..106b6ee3ec 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -393,7 +393,6 @@ class CycleAPIEndpoint(BaseAPIView): class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): - permission_classes = [ ProjectEntityPermission, ] @@ -647,17 +646,6 @@ class CycleIssueAPIEndpoint(BaseAPIView): workspace__slug=slug, project_id=project_id, pk=cycle_id ) - if ( - cycle.end_date is not None - and cycle.end_date < timezone.now().date() - ): - return Response( - { - "error": "The Cycle has already been completed so no new issues can be added" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - issues = Issue.objects.filter( pk__in=issues, workspace__slug=slug, project_id=project_id ).values_list("id", flat=True) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index ce0501dd2e..365e1c470f 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -22,9 +22,11 @@ from django.utils import timezone # Third party imports from rest_framework import status from rest_framework.response import Response +from rest_framework.parsers import MultiPartParser, FormParser # Module imports from plane.api.serializers import ( + IssueAttachmentSerializer, IssueActivitySerializer, IssueCommentSerializer, IssueLinkSerializer, @@ -874,3 +876,83 @@ class IssueActivityAPIEndpoint(BaseAPIView): expand=self.expand, ).data, ) + + +class IssueAttachmentEndpoint(BaseAPIView): + serializer_class = IssueAttachmentSerializer + permission_classes = [ + ProjectEntityPermission, + ] + model = IssueAttachment + parser_classes = (MultiPartParser, FormParser) + + def post(self, request, slug, project_id, issue_id): + serializer = IssueAttachmentSerializer(data=request.data) + if ( + request.data.get("external_id") + and request.data.get("external_source") + and IssueAttachment.objects.filter( + project_id=project_id, + workspace__slug=slug, + issue_id=issue_id, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + issue_attachment = IssueAttachment.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "Issue attachment with the same external id and external source already exists", + "id": str(issue_attachment.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + if serializer.is_valid(): + serializer.save(project_id=project_id, issue_id=issue_id) + issue_activity.delay( + type="attachment.activity.created", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + serializer.data, + cls=DjangoJSONEncoder, + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, slug, project_id, issue_id, pk): + issue_attachment = IssueAttachment.objects.get(pk=pk) + issue_attachment.asset.delete(save=False) + issue_attachment.delete() + issue_activity.delay( + type="attachment.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + + return Response(status=status.HTTP_204_NO_CONTENT) + + def get(self, request, slug, project_id, issue_id): + issue_attachments = IssueAttachment.objects.filter( + issue_id=issue_id, workspace__slug=slug, project_id=project_id + ) + serializer = IssueAttachmentSerializer(issue_attachments, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/member.py b/apiserver/plane/api/views/member.py new file mode 100644 index 0000000000..5d47bbb068 --- /dev/null +++ b/apiserver/plane/api/views/member.py @@ -0,0 +1,147 @@ +# Python imports +import uuid + +# Django imports +from django.contrib.auth.hashers import make_password +from django.core.validators import validate_email +from django.core.exceptions import ValidationError + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from .base import BaseAPIView +from plane.api.serializers import UserLiteSerializer +from plane.db.models import ( + User, + Workspace, + Project, + WorkspaceMember, + ProjectMember, +) + + +# API endpoint to get and insert users inside the workspace +class WorkspaceMemberAPIEndpoint(BaseAPIView): + # Get all the users that are present inside the workspace + def get(self, request, slug): + # Check if the workspace exists + if not Workspace.objects.filter(slug=slug).exists(): + return Response( + {"error": "Provided workspace does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the workspace members that are present inside the workspace + workspace_members = WorkspaceMember.objects.filter( + workspace__slug=slug + ) + + # Get all the users that are present inside the workspace + users = UserLiteSerializer( + User.objects.filter( + id__in=workspace_members.values_list("member_id", flat=True) + ), + many=True, + ).data + + return Response(users, status=status.HTTP_200_OK) + + # Insert a new user inside the workspace, and assign the user to the project + def post(self, request, slug): + # Check if user with email already exists, and send bad request if it's + # not present, check for workspace and valid project mandat + # ------------------- Validation ------------------- + if ( + request.data.get("email") is None + or request.data.get("display_name") is None + or request.data.get("project_id") is None + ): + return Response( + { + "error": "Expected email, display_name, workspace_slug, project_id, one or more of the fields are missing." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + email = request.data.get("email") + + try: + validate_email(email) + except ValidationError: + return Response( + {"error": "Invalid email provided"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = Workspace.objects.filter(slug=slug).first() + project = Project.objects.filter( + pk=request.data.get("project_id") + ).first() + + if not all([workspace, project]): + return Response( + {"error": "Provided workspace or project does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if user exists + user = User.objects.filter(email=email).first() + workspace_member = None + project_member = None + + if user: + # Check if user is part of the workspace + workspace_member = WorkspaceMember.objects.filter( + workspace=workspace, member=user + ).first() + if workspace_member: + # Check if user is part of the project + project_member = ProjectMember.objects.filter( + project=project, member=user + ).first() + if project_member: + return Response( + { + "error": "User is already part of the workspace and project" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # If user does not exist, create the user + if not user: + user = User.objects.create( + email=email, + display_name=request.data.get("display_name"), + first_name=request.data.get("first_name", ""), + last_name=request.data.get("last_name", ""), + username=uuid.uuid4().hex, + password=make_password(uuid.uuid4().hex), + is_password_autoset=True, + is_active=False, + ) + user.save() + + # Create a workspace member for the user if not already a member + if not workspace_member: + workspace_member = WorkspaceMember.objects.create( + workspace=workspace, + member=user, + role=request.data.get("role", 10), + ) + workspace_member.save() + + # Create a project member for the user if not already a member + if not project_member: + project_member = ProjectMember.objects.create( + project=project, + member=user, + role=request.data.get("role", 10), + ) + project_member.save() + + # Serialize the user and return the response + user_data = UserLiteSerializer(user).data + + return Response(user_data, status=status.HTTP_201_CREATED) diff --git a/apiserver/plane/db/migrations/0072_issueattachment_external_id_and_more.py b/apiserver/plane/db/migrations/0072_issueattachment_external_id_and_more.py new file mode 100644 index 0000000000..73d67aad08 --- /dev/null +++ b/apiserver/plane/db/migrations/0072_issueattachment_external_id_and_more.py @@ -0,0 +1,145 @@ +# Generated by Django 4.2.14 on 2024-07-22 13:22 +from django.db import migrations, models +from django.conf import settings +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0071_rename_issueproperty_issueuserproperty_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="issueattachment", + name="external_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="issueattachment", + name="external_source", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.CreateModel( + name="UserRecentVisit", + 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, + ), + ), + ("entity_identifier", models.UUIDField(null=True)), + ( + "entity_name", + models.CharField( + choices=[ + ("VIEW", "View"), + ("PAGE", "Page"), + ("ISSUE", "Issue"), + ("CYCLE", "Cycle"), + ("MODULE", "Module"), + ("PROJECT", "Project"), + ], + max_length=30, + ), + ), + ("visited_at", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_recent_visit", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "User Recent Visit", + "verbose_name_plural": "User Recent Visits", + "db_table": "user_recent_visits", + "ordering": ("-created_at",), + }, + ), + migrations.RemoveField( + model_name="project", + name="start_date", + ), + migrations.RemoveField( + model_name="project", + name="target_date", + ), + migrations.AlterField( + model_name="issuesequence", + name="sequence", + field=models.PositiveBigIntegerField(db_index=True, default=1), + ), + migrations.AlterField( + model_name="project", + name="identifier", + field=models.CharField( + db_index=True, max_length=12, verbose_name="Project Identifier" + ), + ), + migrations.AlterField( + model_name="projectidentifier", + name="name", + field=models.CharField(db_index=True, max_length=12), + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index cee0e18a2c..4874902a42 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -110,3 +110,5 @@ from .dashboard import Dashboard, DashboardWidget, Widget from .favorite import UserFavorite from .issue_type import IssueType + +from .recent_visit import UserRecentVisit \ No newline at end of file diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 0c4373303f..b4c2db9ff6 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -7,8 +7,6 @@ from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models, transaction -from django.db.models.signals import post_save -from django.dispatch import receiver from django.utils import timezone # Module imports @@ -386,6 +384,8 @@ class IssueAttachment(ProjectBaseModel): issue = models.ForeignKey( "db.Issue", on_delete=models.CASCADE, related_name="issue_attachment" ) + external_source = models.CharField(max_length=255, null=True, blank=True) + external_id = models.CharField(max_length=255, blank=True, null=True) class Meta: verbose_name = "Issue Attachment" @@ -578,9 +578,9 @@ class IssueSequence(ProjectBaseModel): Issue, on_delete=models.SET_NULL, related_name="issue_sequence", - null=True, + null=True, # This is set to null because we want to keep the sequence even if the issue is deleted ) - sequence = models.PositiveBigIntegerField(default=1) + sequence = models.PositiveBigIntegerField(default=1, db_index=True) deleted = models.BooleanField(default=False) class Meta: diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 2957562db6..c9a8a34bc2 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -72,6 +72,7 @@ class Project(BaseModel): identifier = models.CharField( max_length=12, verbose_name="Project Identifier", + db_index=True, ) default_assignee = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -117,9 +118,6 @@ class Project(BaseModel): related_name="default_state", ) archived_at = models.DateTimeField(null=True) - # Project start and target date - start_date = models.DateTimeField(null=True, blank=True) - target_date = models.DateTimeField(null=True, blank=True) def __str__(self): """Return name of the project""" @@ -222,7 +220,7 @@ class ProjectIdentifier(AuditModel): project = models.OneToOneField( Project, on_delete=models.CASCADE, related_name="project_identifier" ) - name = models.CharField(max_length=12) + name = models.CharField(max_length=12, db_index=True) class Meta: unique_together = ["name", "workspace"] diff --git a/apiserver/plane/db/models/recent_visit.py b/apiserver/plane/db/models/recent_visit.py new file mode 100644 index 0000000000..4696ead46b --- /dev/null +++ b/apiserver/plane/db/models/recent_visit.py @@ -0,0 +1,38 @@ +# Django imports +from django.db import models +from django.conf import settings + +# Module imports +from .workspace import WorkspaceBaseModel + + +class EntityNameEnum(models.TextChoices): + VIEW = "VIEW", "View" + PAGE = "PAGE", "Page" + ISSUE = "ISSUE", "Issue" + CYCLE = "CYCLE", "Cycle" + MODULE = "MODULE", "Module" + PROJECT = "PROJECT", "Project" + + +class UserRecentVisit(WorkspaceBaseModel): + entity_identifier = models.UUIDField(null=True) + entity_name = models.CharField( + max_length=30, + choices=EntityNameEnum.choices, + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="user_recent_visit", + ) + visited_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "User Recent Visit" + verbose_name_plural = "User Recent Visits" + db_table = "user_recent_visits" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.entity_name} {self.user.email}"