Skip to content

Commit a69fe55

Browse files
committed
Workspace labels #3101
1 parent a4bca0c commit a69fe55

File tree

52 files changed

+460
-347
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+460
-347
lines changed

apiserver/plane/api/serializers/issue.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,6 @@ class Meta:
310310
read_only_fields = [
311311
"id",
312312
"workspace",
313-
"project",
314313
"created_by",
315314
"updated_by",
316315
"created_at",

apiserver/plane/app/serializers/issue.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,11 +303,10 @@ class Meta:
303303
"name",
304304
"color",
305305
"id",
306-
"project_id",
307306
"workspace_id",
308307
"sort_order",
309308
]
310-
read_only_fields = ["workspace", "project"]
309+
read_only_fields = ["workspace"]
311310

312311

313312
class LabelLiteSerializer(BaseSerializer):

apiserver/plane/app/urls/issue.py

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
IssueSubscriberViewSet,
1818
IssueUserDisplayPropertyEndpoint,
1919
IssueViewSet,
20-
LabelViewSet,
2120
BulkArchiveIssuesEndpoint,
2221
DeletedIssuesListViewSet,
2322
IssuePaginatedViewSet,
@@ -65,23 +64,6 @@
6564
),
6665
name="project-issue",
6766
),
68-
path(
69-
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
70-
LabelViewSet.as_view({"get": "list", "post": "create"}),
71-
name="project-issue-labels",
72-
),
73-
path(
74-
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/<uuid:pk>/",
75-
LabelViewSet.as_view(
76-
{
77-
"get": "retrieve",
78-
"put": "update",
79-
"patch": "partial_update",
80-
"delete": "destroy",
81-
}
82-
),
83-
name="project-issue-labels",
84-
),
8567
path(
8668
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-create-labels/",
8769
BulkCreateIssueLabelsEndpoint.as_view(),

apiserver/plane/app/urls/workspace.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
WorkspaceUserProfileEndpoint,
1818
WorkspaceUserProfileIssuesEndpoint,
1919
WorkspaceLabelsEndpoint,
20+
LabelViewSet,
2021
WorkspaceProjectMemberEndpoint,
2122
WorkspaceUserPropertiesEndpoint,
2223
WorkspaceStatesEndpoint,
@@ -161,6 +162,23 @@
161162
WorkspaceLabelsEndpoint.as_view(),
162163
name="workspace-labels",
163164
),
165+
path(
166+
"workspaces/<str:slug>/issue-labels/",
167+
LabelViewSet.as_view({"get": "list", "post": "create"}),
168+
name="workspace-issue-labels",
169+
),
170+
path(
171+
"workspaces/<str:slug>/issue-labels/<uuid:pk>/",
172+
LabelViewSet.as_view(
173+
{
174+
"get": "retrieve",
175+
"put": "update",
176+
"patch": "partial_update",
177+
"delete": "destroy",
178+
}
179+
),
180+
name="workspace-issue-labels",
181+
),
164182
path(
165183
"workspaces/<str:slug>/user-properties/",
166184
WorkspaceUserPropertiesEndpoint.as_view(),

apiserver/plane/app/views/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
WorkspaceJoinEndpoint,
6262
UserWorkspaceInvitationsViewSet,
6363
)
64-
from .workspace.label import WorkspaceLabelsEndpoint
64+
from .workspace.label import LabelViewSet, WorkspaceLabelsEndpoint
6565
from .workspace.state import WorkspaceStatesEndpoint
6666
from .workspace.user import (
6767
UserLastProjectWithWorkspaceEndpoint,
@@ -132,7 +132,7 @@
132132

133133
from .issue.comment import IssueCommentViewSet, CommentReactionViewSet
134134

135-
from .issue.label import LabelViewSet, BulkCreateIssueLabelsEndpoint
135+
from .issue.label import BulkCreateIssueLabelsEndpoint
136136

137137
from .issue.link import IssueLinkViewSet
138138

apiserver/plane/app/views/issue/label.py

Lines changed: 3 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -9,74 +9,13 @@
99
from rest_framework import status
1010

1111
# Module imports
12-
from .. import BaseViewSet, BaseAPIView
12+
from .. import BaseAPIView
1313
from plane.app.serializers import LabelSerializer
14-
from plane.app.permissions import allow_permission, ProjectBasePermission, ROLE
15-
from plane.db.models import Project, Label
14+
from plane.app.permissions import allow_permission, WorkSpaceBasePermission, ROLE
15+
from plane.db.models import Project, Label, Workspace
1616
from plane.utils.cache import invalidate_cache
1717

1818

19-
class LabelViewSet(BaseViewSet):
20-
serializer_class = LabelSerializer
21-
model = Label
22-
permission_classes = [ProjectBasePermission]
23-
24-
def get_queryset(self):
25-
return self.filter_queryset(
26-
super()
27-
.get_queryset()
28-
.filter(workspace__slug=self.kwargs.get("slug"))
29-
.filter(project_id=self.kwargs.get("project_id"))
30-
.filter(project__project_projectmember__member=self.request.user)
31-
.select_related("project")
32-
.select_related("workspace")
33-
.select_related("parent")
34-
.distinct()
35-
.order_by("sort_order")
36-
)
37-
38-
@invalidate_cache(
39-
path="/api/workspaces/:slug/labels/", url_params=True, user=False, multiple=True
40-
)
41-
@allow_permission([ROLE.ADMIN])
42-
def create(self, request, slug, project_id):
43-
try:
44-
serializer = LabelSerializer(data=request.data)
45-
if serializer.is_valid():
46-
serializer.save(project_id=project_id)
47-
return Response(serializer.data, status=status.HTTP_201_CREATED)
48-
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
49-
except IntegrityError:
50-
return Response(
51-
{"error": "Label with the same name already exists in the project"},
52-
status=status.HTTP_400_BAD_REQUEST,
53-
)
54-
55-
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
56-
@allow_permission([ROLE.ADMIN])
57-
def partial_update(self, request, *args, **kwargs):
58-
# Check if the label name is unique within the project
59-
if (
60-
"name" in request.data
61-
and Label.objects.filter(
62-
project_id=kwargs["project_id"], name=request.data["name"]
63-
)
64-
.exclude(pk=kwargs["pk"])
65-
.exists()
66-
):
67-
return Response(
68-
{"error": "Label with the same name already exists in the project"},
69-
status=status.HTTP_400_BAD_REQUEST,
70-
)
71-
# call the parent method to perform the update
72-
return super().partial_update(request, *args, **kwargs)
73-
74-
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
75-
@allow_permission([ROLE.ADMIN])
76-
def destroy(self, request, *args, **kwargs):
77-
return super().destroy(request, *args, **kwargs)
78-
79-
8019
class BulkCreateIssueLabelsEndpoint(BaseAPIView):
8120
@allow_permission([ROLE.ADMIN])
8221
def post(self, request, slug, project_id):
Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
# Django imports
2+
from django.db import IntegrityError
3+
14
# Third party modules
25
from rest_framework import status
36
from rest_framework.response import Response
47

58
# Module imports
69
from plane.app.serializers import LabelSerializer
7-
from plane.app.views.base import BaseAPIView
10+
from plane.app.views.base import BaseAPIView, BaseViewSet
811
from plane.db.models import Label
9-
from plane.app.permissions import WorkspaceViewerPermission
10-
from plane.utils.cache import cache_response
12+
from plane.utils.cache import cache_response, invalidate_cache
13+
from plane.app.permissions.workspace import WorkSpaceBasePermission, WorkspaceViewerPermission
14+
from plane.app.permissions.base import allow_permission, ROLE
15+
from plane.db.models.workspace import Workspace
1116

1217

1318
class WorkspaceLabelsEndpoint(BaseAPIView):
@@ -17,9 +22,66 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
1722
def get(self, request, slug):
1823
labels = Label.objects.filter(
1924
workspace__slug=slug,
20-
project__project_projectmember__member=request.user,
21-
project__project_projectmember__is_active=True,
22-
project__archived_at__isnull=True,
2325
)
2426
serializer = LabelSerializer(labels, many=True).data
2527
return Response(serializer, status=status.HTTP_200_OK)
28+
29+
30+
class LabelViewSet(BaseViewSet):
31+
serializer_class = LabelSerializer
32+
model = Label
33+
permission_classes = [WorkSpaceBasePermission]
34+
35+
def get_queryset(self):
36+
return self.filter_queryset(
37+
super()
38+
.get_queryset()
39+
.filter(workspace__slug=self.kwargs.get("slug"))
40+
.select_related("workspace")
41+
.select_related("parent")
42+
.distinct()
43+
.order_by("sort_order")
44+
)
45+
46+
@invalidate_cache(
47+
path="/api/workspaces/:slug/labels/", url_params=True, user=False, multiple=True
48+
)
49+
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
50+
def create(self, request, slug):
51+
try:
52+
ws = Workspace.objects.filter(slug=slug).first()
53+
serializer = LabelSerializer(data=request.data)
54+
if serializer.is_valid():
55+
serializer.save(workspace=ws)
56+
return Response(serializer.data, status=status.HTTP_201_CREATED)
57+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
58+
except IntegrityError:
59+
return Response(
60+
{"error": "Label with the same name already exists in the project"},
61+
status=status.HTTP_400_BAD_REQUEST,
62+
)
63+
64+
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
65+
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
66+
def partial_update(self, request, *args, **kwargs):
67+
# Check if the label name is unique within the project
68+
if (
69+
"name" in request.data
70+
and Label.objects.filter(
71+
workspace__slug=kwargs['slug'],
72+
name=request.data["name"]
73+
)
74+
.exclude(pk=kwargs["pk"])
75+
.exists()
76+
):
77+
return Response(
78+
{"error": "Label with the same name already exists in the workspace"},
79+
status=status.HTTP_400_BAD_REQUEST,
80+
)
81+
# call the parent method to perform the update
82+
return super().partial_update(request, *args, **kwargs)
83+
84+
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
85+
@allow_permission([ROLE.ADMIN], level="WORKSPACE")
86+
def destroy(self, request, *args, **kwargs):
87+
return super().destroy(request, *args, **kwargs)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Generated by Django 4.2.20 on 2025-04-07 07:44
2+
3+
from django.db import migrations, transaction
4+
5+
from plane.db.models import Issue, IssueLabel
6+
7+
def clear_label_project(apps, schema_editor):
8+
Label = apps.get_model('db', 'Label')
9+
for label in Label.objects.all():
10+
existing = Label.objects.filter(name=label.name, project_id=None).first()
11+
if existing is None:
12+
label.project = None
13+
label.save()
14+
else:
15+
### Need to move all the current's label issues to existing one.
16+
# Process issues in batches to avoid long-running transactions
17+
issues_to_move = list(Issue.objects.filter(labels__id=label.id).all())
18+
for batch in [issues_to_move[i:i+100] for i in range(0, len(issues_to_move), 100)]:
19+
with transaction.atomic():
20+
for issue_to_move in batch:
21+
issue_to_move.labels.remove(label.id)
22+
l_connection = IssueLabel.objects.create(
23+
issue=issue_to_move,
24+
label_id=existing.id,
25+
workspace=issue_to_move.workspace,
26+
project=issue_to_move.project,
27+
)
28+
issue_to_move.save()
29+
l_connection.save()
30+
label.delete()
31+
32+
class Migration(migrations.Migration):
33+
34+
dependencies = [
35+
('db', '0092_alter_deprecateddashboardwidget_unique_together_and_more'),
36+
]
37+
38+
operations = [
39+
migrations.RunPython(clear_label_project, reverse_code=migrations.RunPython.noop),
40+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Generated by Django 4.2.20 on 2025-04-07 09:39
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('db', '0093_manage_label_project'),
10+
]
11+
12+
operations = [
13+
migrations.RemoveConstraint(
14+
model_name='label',
15+
name='unique_name_when_project_null_and_not_deleted',
16+
),
17+
migrations.RemoveConstraint(
18+
model_name='label',
19+
name='unique_project_name_when_not_deleted',
20+
),
21+
migrations.RemoveField(
22+
model_name='label',
23+
name='project',
24+
),
25+
]

apiserver/plane/db/models/label.py

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from django.db import models
22
from django.db.models import Q
33

4-
from .workspace import WorkspaceBaseModel
4+
from .workspace import BaseModel
55

66

7-
class Label(WorkspaceBaseModel):
7+
class Label(BaseModel):
88
parent = models.ForeignKey(
99
"self",
1010
on_delete=models.CASCADE,
@@ -18,22 +18,11 @@ class Label(WorkspaceBaseModel):
1818
sort_order = models.FloatField(default=65535)
1919
external_source = models.CharField(max_length=255, null=True, blank=True)
2020
external_id = models.CharField(max_length=255, blank=True, null=True)
21+
workspace = models.ForeignKey(
22+
"db.Workspace", models.CASCADE, related_name="workspace_%(class)s"
23+
)
2124

2225
class Meta:
23-
constraints = [
24-
# Enforce uniqueness of name when project is NULL and deleted_at is NULL
25-
models.UniqueConstraint(
26-
fields=["name"],
27-
condition=Q(project__isnull=True, deleted_at__isnull=True),
28-
name="unique_name_when_project_null_and_not_deleted",
29-
),
30-
# Enforce uniqueness of project and name when project is not NULL and deleted_at is NULL
31-
models.UniqueConstraint(
32-
fields=["project", "name"],
33-
condition=Q(project__isnull=False, deleted_at__isnull=True),
34-
name="unique_project_name_when_not_deleted",
35-
),
36-
]
3726
verbose_name = "Label"
3827
verbose_name_plural = "Labels"
3928
db_table = "labels"
@@ -42,7 +31,7 @@ class Meta:
4231
def save(self, *args, **kwargs):
4332
if self._state.adding:
4433
# Get the maximum sequence value from the database
45-
last_id = Label.objects.filter(project=self.project).aggregate(
34+
last_id = Label.objects.filter(workspace=self.workspace).aggregate(
4635
largest=models.Max("sort_order")
4736
)["largest"]
4837
# if last_id is not None

0 commit comments

Comments
 (0)