Merge branch 'chore-user-property-migrations' of github.com:makeplane/plane into workspace-api-tokens

This commit is contained in:
sangeethailango
2025-12-19 16:36:04 +05:30
18 changed files with 223 additions and 117 deletions

View File

@@ -18,7 +18,7 @@ from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
from plane.db.models import (
Cycle,
Intake,
IssueUserProperty,
ProjectUserProperty,
Module,
Project,
DeployBoard,
@@ -216,8 +216,6 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
# Add the user as Administrator to the project
_ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, role=20)
# Also create the issue property for the user
_ = IssueUserProperty.objects.create(project_id=serializer.instance.id, user=request.user)
if serializer.instance.project_lead is not None and str(serializer.instance.project_lead) != str(
request.user.id
@@ -227,11 +225,6 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
member_id=serializer.instance.project_lead,
role=20,
)
# Also create the issue property for the user
IssueUserProperty.objects.create(
project_id=serializer.instance.id,
user_id=serializer.instance.project_lead,
)
State.objects.bulk_create(
[

View File

@@ -52,7 +52,7 @@ from .issue import (
IssueCreateSerializer,
IssueActivitySerializer,
IssueCommentSerializer,
IssueUserPropertySerializer,
ProjectUserPropertySerializer,
IssueAssigneeSerializer,
LabelSerializer,
IssueSerializer,

View File

@@ -18,7 +18,7 @@ from plane.db.models import (
Issue,
IssueActivity,
IssueComment,
IssueUserProperty,
ProjectUserProperty,
IssueAssignee,
IssueSubscriber,
IssueLabel,
@@ -346,9 +346,9 @@ class IssueActivitySerializer(BaseSerializer):
fields = "__all__"
class IssueUserPropertySerializer(BaseSerializer):
class ProjectUserPropertySerializer(BaseSerializer):
class Meta:
model = IssueUserProperty
model = ProjectUserProperty
fields = "__all__"
read_only_fields = ["user", "workspace", "project"]

View File

@@ -14,7 +14,7 @@ from plane.app.views import (
IssueReactionViewSet,
IssueRelationViewSet,
IssueSubscriberViewSet,
IssueUserDisplayPropertyEndpoint,
ProjectUserDisplayPropertyEndpoint,
IssueViewSet,
LabelViewSet,
BulkArchiveIssuesEndpoint,
@@ -208,13 +208,13 @@ urlpatterns = [
name="project-issue-comment-reactions",
),
## End Comment Reactions
## IssueUserProperty
## ProjectUserProperty
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-properties/",
IssueUserDisplayPropertyEndpoint.as_view(),
ProjectUserDisplayPropertyEndpoint.as_view(),
name="project-issue-display-properties",
),
## IssueUserProperty End
## ProjectUserProperty End
## Issue Archives
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/",

View File

@@ -115,7 +115,7 @@ from .asset.v2 import (
from .issue.base import (
IssueListEndpoint,
IssueViewSet,
IssueUserDisplayPropertyEndpoint,
ProjectUserDisplayPropertyEndpoint,
BulkDeleteIssuesEndpoint,
DeletedIssuesListViewSet,
IssuePaginatedViewSet,

View File

@@ -34,7 +34,7 @@ from plane.app.serializers import (
IssueDetailSerializer,
IssueListDetailSerializer,
IssueSerializer,
IssueUserPropertySerializer,
ProjectUserPropertySerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_description_version_task import issue_description_version_task
@@ -51,7 +51,7 @@ from plane.db.models import (
IssueReaction,
IssueRelation,
IssueSubscriber,
IssueUserProperty,
ProjectUserProperty,
ModuleIssue,
Project,
ProjectMember,
@@ -715,23 +715,33 @@ class IssueViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class IssueUserDisplayPropertyEndpoint(BaseAPIView):
class ProjectUserDisplayPropertyEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def patch(self, request, slug, project_id):
issue_property = IssueUserProperty.objects.get(user=request.user, project_id=project_id)
try:
issue_property = ProjectUserProperty.objects.get(
user=request.user,
project_id=project_id
)
except ProjectUserProperty.DoesNotExist:
issue_property = ProjectUserProperty.objects.create(
user=request.user,
project_id=project_id
)
issue_property.rich_filters = request.data.get("rich_filters", issue_property.rich_filters)
issue_property.filters = request.data.get("filters", issue_property.filters)
issue_property.display_filters = request.data.get("display_filters", issue_property.display_filters)
issue_property.display_properties = request.data.get("display_properties", issue_property.display_properties)
issue_property.save()
serializer = IssueUserPropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_201_CREATED)
serializer = ProjectUserPropertySerializer(
issue_property,
data=request.data,
partial=True
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
issue_property, _ = IssueUserProperty.objects.get_or_create(user=request.user, project_id=project_id)
serializer = IssueUserPropertySerializer(issue_property)
issue_property, _ = ProjectUserProperty.objects.get_or_create(user=request.user, project_id=project_id)
serializer = ProjectUserPropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -27,14 +27,15 @@ from plane.bgtasks.webhook_task import model_activity, webhook_activity
from plane.db.models import (
UserFavorite,
DeployBoard,
ProjectUserProperty,
Intake,
IssueUserProperty,
Project,
ProjectIdentifier,
ProjectMember,
ProjectNetwork,
State,
DEFAULT_STATES,
UserFavorite,
Workspace,
WorkspaceMember,
)
@@ -256,7 +257,7 @@ class ProjectViewSet(BaseViewSet):
role=ROLE.ADMIN.value,
)
# Also create the issue property for the user
_ = IssueUserProperty.objects.create(project_id=serializer.data["id"], user=request.user)
_ = ProjectUserProperty.objects.create(project_id=serializer.data["id"], user=request.user)
if serializer.data["project_lead"] is not None and str(serializer.data["project_lead"]) != str(
request.user.id
@@ -266,11 +267,6 @@ class ProjectViewSet(BaseViewSet):
member_id=serializer.data["project_lead"],
role=ROLE.ADMIN.value,
)
# Also create the issue property for the user
IssueUserProperty.objects.create(
project_id=serializer.data["id"],
user_id=serializer.data["project_lead"],
)
State.objects.bulk_create(
[

View File

@@ -24,7 +24,7 @@ from plane.db.models import (
User,
WorkspaceMember,
Project,
IssueUserProperty,
ProjectUserProperty,
)
from plane.db.models.project import ProjectNetwork
from plane.utils.host import base_host
@@ -160,9 +160,9 @@ class UserProjectInvitationsViewset(BaseViewSet):
ignore_conflicts=True,
)
IssueUserProperty.objects.bulk_create(
ProjectUserProperty.objects.bulk_create(
[
IssueUserProperty(
ProjectUserProperty(
project_id=project_id,
user=request.user,
workspace=workspace,
@@ -220,7 +220,7 @@ class ProjectJoinEndpoint(BaseAPIView):
if project_member is None:
# Create a Project Member
_ = ProjectMember.objects.create(
workspace_id=project_invite.workspace_id,
project_id=project_id,
member=user,
role=project_invite.role,
)

View File

@@ -1,6 +1,7 @@
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from django.db.models import Min
# Module imports
from .base import BaseViewSet, BaseAPIView
@@ -13,7 +14,7 @@ from plane.app.serializers import (
from plane.app.permissions import WorkspaceUserPermission
from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
from plane.db.models import Project, ProjectMember, ProjectUserProperty, WorkspaceMember
from plane.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host
from plane.app.permissions.base import allow_permission, ROLE
@@ -89,24 +90,23 @@ class ProjectMemberViewSet(BaseViewSet):
# Update the roles of the existing members
ProjectMember.objects.bulk_update(bulk_project_members, ["is_active", "role"], batch_size=100)
# Get the list of project members of the requested workspace with the given slug
project_members = (
ProjectMember.objects.filter(
# Get the minimum sort_order for each member in the workspace
member_sort_orders = (
ProjectUserProperty.objects.filter(
workspace__slug=slug,
member_id__in=[member.get("member_id") for member in members],
user_id__in=[member.get("member_id") for member in members],
)
.values("member_id", "sort_order")
.order_by("sort_order")
.values("user_id")
.annotate(min_sort_order=Min("sort_order"))
)
# Convert to dictionary for easy lookup: {user_id: min_sort_order}
sort_order_map = {str(item["user_id"]): item["min_sort_order"] for item in member_sort_orders}
# Loop through requested members
for member in members:
# Get the sort orders of the member
sort_order = [
project_member.get("sort_order")
for project_member in project_members
if str(project_member.get("member_id")) == str(member.get("member_id"))
]
member_id = str(member.get("member_id"))
# Get the minimum sort_order for this member, or use default
min_sort_order = sort_order_map.get(member_id)
# Create a new project member
bulk_project_members.append(
ProjectMember(
@@ -114,22 +114,22 @@ class ProjectMemberViewSet(BaseViewSet):
role=member.get("role", 5),
project_id=project_id,
workspace_id=project.workspace_id,
sort_order=(sort_order[0] - 10000 if len(sort_order) else 65535),
)
)
# Create a new issue property
bulk_issue_props.append(
IssueUserProperty(
ProjectUserProperty(
user_id=member.get("member_id"),
project_id=project_id,
workspace_id=project.workspace_id,
sort_order=(min_sort_order - 10000 if min_sort_order is not None else 65535),
)
)
# Bulk create the project members and issue properties
project_members = ProjectMember.objects.bulk_create(bulk_project_members, batch_size=10, ignore_conflicts=True)
_ = IssueUserProperty.objects.bulk_create(bulk_issue_props, batch_size=10, ignore_conflicts=True)
_ = ProjectUserProperty.objects.bulk_create(bulk_issue_props, batch_size=10, ignore_conflicts=True)
project_members = ProjectMember.objects.filter(
project_id=project_id,

View File

@@ -20,7 +20,7 @@ from plane.db.models import (
WorkspaceMember,
Project,
ProjectMember,
IssueUserProperty,
ProjectUserProperty,
State,
Label,
Issue,
@@ -118,9 +118,9 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
)
# Create issue user properties
IssueUserProperty.objects.bulk_create(
ProjectUserProperty.objects.bulk_create(
[
IssueUserProperty(
ProjectUserProperty(
project=project,
user_id=workspace_member["member_id"],
workspace_id=workspace.id,

View File

@@ -8,7 +8,7 @@ from plane.db.models import (
WorkspaceMember,
ProjectMember,
Project,
IssueUserProperty,
ProjectUserProperty,
)
@@ -47,27 +47,18 @@ class Command(BaseCommand):
if not WorkspaceMember.objects.filter(workspace=project.workspace, member=user, is_active=True).exists():
raise CommandError("User not member in workspace")
# Get the smallest sort order
smallest_sort_order = (
ProjectMember.objects.filter(workspace_id=project.workspace_id).order_by("sort_order").first()
)
if smallest_sort_order:
sort_order = smallest_sort_order.sort_order - 1000
else:
sort_order = 65535
if ProjectMember.objects.filter(project=project, member=user).exists():
# Update the project member
ProjectMember.objects.filter(project=project, member=user).update(
is_active=True, sort_order=sort_order, role=role
is_active=True, role=role
)
else:
# Create the project member
ProjectMember.objects.create(project=project, member=user, role=role, sort_order=sort_order)
ProjectMember.objects.create(project=project, member=user, role=role)
# Issue Property
IssueUserProperty.objects.get_or_create(user=user, project=project)
ProjectUserProperty.objects.get_or_create(user=user, project=project)
# Success message
self.stdout.write(self.style.SUCCESS(f"User {user_email} added to project {project_id}"))

View File

@@ -0,0 +1,55 @@
# Generated by Django 4.2.25 on 2025-11-28 14:36
from django.db import migrations, models
import plane.db.models.project
import django.db.models.deletion
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
('db', '0112_auto_20251124_0603'),
]
operations = [
migrations.AlterModelTable(
name='issueuserproperty',
table='project_user_properties',
),
migrations.RenameModel(
old_name='IssueUserProperty',
new_name='ProjectUserProperty',
),
migrations.AddField(
model_name='apitoken',
name='allowed_rate_limit',
field=models.CharField(default='60/min', max_length=255),
),
migrations.AddField(
model_name='projectuserproperty',
name='preferences',
field=models.JSONField(default=plane.db.models.project.get_default_preferences),
),
migrations.AddField(
model_name='projectuserproperty',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AlterModelOptions(
name='projectuserproperty',
options={'ordering': ('-created_at',), 'verbose_name': 'Project User Property', 'verbose_name_plural': 'Project User Properties'},
),
migrations.RemoveConstraint(
model_name='projectuserproperty',
name='issue_user_property_unique_user_project_when_deleted_at_null',
),
migrations.AlterField(
model_name='projectuserproperty',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_property_user', to=settings.AUTH_USER_MODEL),
),
migrations.AddConstraint(
model_name='projectuserproperty',
constraint=models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True)), fields=('user', 'project'), name='project_user_property_unique_user_project_when_deleted_at_null'),
),
]

View File

@@ -0,0 +1,51 @@
# Generated by Django 4.2.25 on 2025-12-01 13:33
from django.db import migrations
def move_issue_user_properties_to_project_user_properties(apps, schema_editor):
ProjectMember = apps.get_model('db', 'ProjectMember')
ProjectUserProperty = apps.get_model('db', 'ProjectUserProperty')
# Get all project members
project_members = ProjectMember.objects.filter(deleted_at__isnull=True).values('member_id', 'project_id', 'preferences', 'sort_order')
# create a mapping with consistent ordering
pm_dict = {
(pm['member_id'], pm['project_id']): pm
for pm in project_members
}
# Get all project user properties
properties_to_update = []
for projectuserproperty in ProjectUserProperty.objects.filter(deleted_at__isnull=True):
pm = pm_dict.get((projectuserproperty.user_id, projectuserproperty.project_id))
if pm:
projectuserproperty.preferences = pm['preferences']
projectuserproperty.sort_order = pm['sort_order']
properties_to_update.append(projectuserproperty)
ProjectUserProperty.objects.bulk_update(properties_to_update, ['preferences', 'sort_order'], batch_size=2000)
def migrate_existing_api_tokens(apps, schema_editor):
APIToken = apps.get_model('db', 'APIToken')
# Update all the existing non-service api tokens to not have a workspace
APIToken.objects.filter(is_service=False, user__is_bot=False).update(
workspace_id=None,
)
return
class Migration(migrations.Migration):
dependencies = [
('db', '0113_alter_issueuserproperty_table'),
]
operations = [
migrations.RunPython(move_issue_user_properties_to_project_user_properties, reverse_code=migrations.RunPython.noop),
migrations.RunPython(migrate_existing_api_tokens, reverse_code=migrations.RunPython.noop),
]

View File

@@ -34,7 +34,6 @@ from .issue import (
IssueLabel,
IssueLink,
IssueMention,
IssueUserProperty,
IssueReaction,
IssueRelation,
IssueSequence,
@@ -54,6 +53,7 @@ from .project import (
ProjectMemberInvite,
ProjectNetwork,
ProjectPublicMember,
ProjectUserProperty,
)
from .session import Session
from .social_connection import SocialLoginConnection

View File

@@ -32,6 +32,7 @@ class APIToken(BaseModel):
workspace = models.ForeignKey("db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True)
expired_at = models.DateTimeField(blank=True, null=True)
is_service = models.BooleanField(default=False)
allowed_rate_limit = models.CharField(max_length=255, default="60/min")
class Meta:
verbose_name = "API Token"

View File

@@ -536,36 +536,6 @@ class IssueComment(ChangeTrackerMixin, ProjectBaseModel):
return str(self.issue)
class IssueUserProperty(ProjectBaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_property_user",
)
filters = models.JSONField(default=get_default_filters)
display_filters = models.JSONField(default=get_default_display_filters)
display_properties = models.JSONField(default=get_default_display_properties)
rich_filters = models.JSONField(default=dict)
class Meta:
verbose_name = "Issue User Property"
verbose_name_plural = "Issue User Properties"
db_table = "issue_user_properties"
ordering = ("-created_at",)
unique_together = ["user", "project", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["user", "project"],
condition=Q(deleted_at__isnull=True),
name="issue_user_property_unique_user_project_when_deleted_at_null",
)
]
def __str__(self):
"""Return properties status of the issue"""
return str(self.user)
class IssueLabel(ProjectBaseModel):
issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="label_issue")
label = models.ForeignKey("db.Label", on_delete=models.CASCADE, related_name="label_issue")

View File

@@ -12,7 +12,6 @@ from django.db.models import Q
# Module imports
from plane.db.mixins import AuditModel
# Module imports
from .base import BaseModel
ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest"))
@@ -206,14 +205,20 @@ class ProjectMember(ProjectBaseModel):
is_active = models.BooleanField(default=True)
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = ProjectMember.objects.filter(
workspace_id=self.project.workspace_id, member=self.member
).aggregate(smallest=models.Min("sort_order"))["smallest"]
if self._state.adding and self.member:
# Get the minimum sort_order for this member in the workspace
min_sort_order_result = ProjectUserProperty.objects.filter(
workspace_id=self.project.workspace_id, user=self.member
).aggregate(min_sort_order=models.Min("sort_order"))
min_sort_order = min_sort_order_result.get("min_sort_order")
# Project ordering
if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000
# create project user property with project sort order
ProjectUserProperty.objects.create(
workspace_id=self.project.workspace_id,
project=self.project,
user=self.member,
sort_order=(min_sort_order - 10000 if min_sort_order is not None else 65535),
)
super(ProjectMember, self).save(*args, **kwargs)
@@ -313,3 +318,37 @@ class ProjectPublicMember(ProjectBaseModel):
verbose_name_plural = "Project Public Members"
db_table = "project_public_members"
ordering = ("-created_at",)
class ProjectUserProperty(ProjectBaseModel):
from .issue import get_default_filters, get_default_display_filters, get_default_display_properties
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="project_property_user",
)
filters = models.JSONField(default=get_default_filters)
display_filters = models.JSONField(default=get_default_display_filters)
display_properties = models.JSONField(default=get_default_display_properties)
rich_filters = models.JSONField(default=dict)
preferences = models.JSONField(default=get_default_preferences)
sort_order = models.FloatField(default=65535)
class Meta:
verbose_name = "Project User Property"
verbose_name_plural = "Project User Properties"
db_table = "project_user_properties"
ordering = ("-created_at",)
unique_together = ["user", "project", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["user", "project"],
condition=Q(deleted_at__isnull=True),
name="project_user_property_unique_user_project_when_deleted_at_null",
)
]
def __str__(self):
"""Return properties status of the project"""
return str(self.user)

View File

@@ -6,7 +6,7 @@ from django.utils import timezone
from plane.db.models import (
Project,
ProjectMember,
IssueUserProperty,
ProjectUserProperty,
State,
WorkspaceMember,
User,
@@ -82,8 +82,8 @@ class TestProjectAPIPost(TestProjectBase):
assert project_member.role == 20 # Administrator
assert project_member.is_active is True
# Verify IssueUserProperty was created
assert IssueUserProperty.objects.filter(project=project, user=user).exists()
# Verify ProjectUserProperty was created
assert ProjectUserProperty.objects.filter(project=project, user=user).exists()
# Verify default states were created
states = State.objects.filter(project=project)
@@ -116,8 +116,8 @@ class TestProjectAPIPost(TestProjectBase):
project = Project.objects.get(name=project_data["name"])
assert ProjectMember.objects.filter(project=project, role=20).count() == 2
# Verify both have IssueUserProperty
assert IssueUserProperty.objects.filter(project=project).count() == 2
# Verify both have ProjectUserProperty
assert ProjectUserProperty.objects.filter(project=project).count() == 2
@pytest.mark.django_db
def test_create_project_guest_forbidden(self, session_client, workspace):