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 <nikhilschacko@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
Henit Chobisa 2024-07-23 19:20:50 +05:30 committed by GitHub
parent 66c2cbe7d6
commit 3a6d3d4e82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 444 additions and 20 deletions

View file

@ -4,6 +4,7 @@ from .issue import urlpatterns as issue_patterns
from .cycle import urlpatterns as cycle_patterns from .cycle import urlpatterns as cycle_patterns
from .module import urlpatterns as module_patterns from .module import urlpatterns as module_patterns
from .inbox import urlpatterns as inbox_patterns from .inbox import urlpatterns as inbox_patterns
from .member import urlpatterns as member_patterns
urlpatterns = [ urlpatterns = [
*project_patterns, *project_patterns,
@ -12,4 +13,5 @@ urlpatterns = [
*cycle_patterns, *cycle_patterns,
*module_patterns, *module_patterns,
*inbox_patterns, *inbox_patterns,
*member_patterns,
] ]

View file

@ -7,6 +7,7 @@ from plane.api.views import (
IssueCommentAPIEndpoint, IssueCommentAPIEndpoint,
IssueActivityAPIEndpoint, IssueActivityAPIEndpoint,
WorkspaceIssueAPIEndpoint, WorkspaceIssueAPIEndpoint,
IssueAttachmentEndpoint,
) )
urlpatterns = [ urlpatterns = [
@ -65,4 +66,9 @@ urlpatterns = [
IssueActivityAPIEndpoint.as_view(), IssueActivityAPIEndpoint.as_view(),
name="activity", name="activity",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
IssueAttachmentEndpoint.as_view(),
name="attachment",
),
] ]

View file

@ -0,0 +1,13 @@
from django.urls import path
from plane.api.views import (
WorkspaceMemberAPIEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/members/",
WorkspaceMemberAPIEndpoint.as_view(),
name="users",
),
]

View file

@ -9,6 +9,7 @@ from .issue import (
IssueLinkAPIEndpoint, IssueLinkAPIEndpoint,
IssueCommentAPIEndpoint, IssueCommentAPIEndpoint,
IssueActivityAPIEndpoint, IssueActivityAPIEndpoint,
IssueAttachmentEndpoint,
) )
from .cycle import ( from .cycle import (
@ -24,4 +25,6 @@ from .module import (
ModuleArchiveUnarchiveAPIEndpoint, ModuleArchiveUnarchiveAPIEndpoint,
) )
from .member import WorkspaceMemberAPIEndpoint
from .inbox import InboxIssueAPIEndpoint from .inbox import InboxIssueAPIEndpoint

View file

@ -393,7 +393,6 @@ class CycleAPIEndpoint(BaseAPIView):
class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
] ]
@ -647,17 +646,6 @@ class CycleIssueAPIEndpoint(BaseAPIView):
workspace__slug=slug, project_id=project_id, pk=cycle_id 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( issues = Issue.objects.filter(
pk__in=issues, workspace__slug=slug, project_id=project_id pk__in=issues, workspace__slug=slug, project_id=project_id
).values_list("id", flat=True) ).values_list("id", flat=True)

View file

@ -22,9 +22,11 @@ from django.utils import timezone
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser
# Module imports # Module imports
from plane.api.serializers import ( from plane.api.serializers import (
IssueAttachmentSerializer,
IssueActivitySerializer, IssueActivitySerializer,
IssueCommentSerializer, IssueCommentSerializer,
IssueLinkSerializer, IssueLinkSerializer,
@ -874,3 +876,83 @@ class IssueActivityAPIEndpoint(BaseAPIView):
expand=self.expand, expand=self.expand,
).data, ).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)

View file

@ -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)

View file

@ -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),
),
]

View file

@ -110,3 +110,5 @@ from .dashboard import Dashboard, DashboardWidget, Widget
from .favorite import UserFavorite from .favorite import UserFavorite
from .issue_type import IssueType from .issue_type import IssueType
from .recent_visit import UserRecentVisit

View file

@ -7,8 +7,6 @@ from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction from django.db import models, transaction
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
# Module imports # Module imports
@ -386,6 +384,8 @@ class IssueAttachment(ProjectBaseModel):
issue = models.ForeignKey( issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="issue_attachment" "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: class Meta:
verbose_name = "Issue Attachment" verbose_name = "Issue Attachment"
@ -578,9 +578,9 @@ class IssueSequence(ProjectBaseModel):
Issue, Issue,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="issue_sequence", 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) deleted = models.BooleanField(default=False)
class Meta: class Meta:

View file

@ -72,6 +72,7 @@ class Project(BaseModel):
identifier = models.CharField( identifier = models.CharField(
max_length=12, max_length=12,
verbose_name="Project Identifier", verbose_name="Project Identifier",
db_index=True,
) )
default_assignee = models.ForeignKey( default_assignee = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
@ -117,9 +118,6 @@ class Project(BaseModel):
related_name="default_state", related_name="default_state",
) )
archived_at = models.DateTimeField(null=True) 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): def __str__(self):
"""Return name of the project""" """Return name of the project"""
@ -222,7 +220,7 @@ class ProjectIdentifier(AuditModel):
project = models.OneToOneField( project = models.OneToOneField(
Project, on_delete=models.CASCADE, related_name="project_identifier" 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: class Meta:
unique_together = ["name", "workspace"] unique_together = ["name", "workspace"]

View file

@ -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}"