Compare commits

..

6 Commits

Author SHA1 Message Date
pablohashescobar 18865b46fe chore: update instance admin 2024-11-19 20:50:30 +05:30
sriram veeraghanta 2d60337eac fix: celery timestamp changes 2024-11-19 20:17:53 +05:30
pablohashescobar f3ac26e5c9 chore: instances 2024-11-19 19:47:13 +05:30
Aaryan Khandelwal d5a55de17a fix: cover image update fix for project and user profile (#6075)
* fix: cover image update payload

* fix: cover image assets

* chore: add gif support

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2024-11-19 18:28:53 +05:30
Prateek Shourya 6f497b024b [WEB-2770] fix: inbox issue detail loader on focus change (#6074) 2024-11-19 17:07:32 +05:30
Nikhil a3e8ee6045 fix: remove caching for user based apis to handle avatar uploads (#6072) 2024-11-19 15:42:10 +05:30
29 changed files with 432 additions and 479 deletions
@@ -13,6 +13,7 @@ from .user import (
from .workspace import (
WorkSpaceSerializer,
WorkSpaceMemberSerializer,
TeamSerializer,
WorkSpaceMemberInviteSerializer,
WorkspaceLiteSerializer,
WorkspaceThemeSerializer,
+52 -1
View File
@@ -9,6 +9,8 @@ from plane.db.models import (
User,
Workspace,
WorkspaceMember,
Team,
TeamMember,
WorkspaceMemberInvite,
WorkspaceTheme,
WorkspaceUserProperties,
@@ -64,7 +66,6 @@ class WorkSpaceMemberSerializer(DynamicBaseSerializer):
class WorkspaceMemberMeSerializer(BaseSerializer):
draft_issue_count = serializers.IntegerField(read_only=True)
class Meta:
model = WorkspaceMember
fields = "__all__"
@@ -99,6 +100,56 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
]
class TeamSerializer(BaseSerializer):
members_detail = UserLiteSerializer(
read_only=True, source="members", many=True
)
members = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
)
class Meta:
model = Team
fields = "__all__"
read_only_fields = [
"workspace",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
def create(self, validated_data, **kwargs):
if "members" in validated_data:
members = validated_data.pop("members")
workspace = self.context["workspace"]
team = Team.objects.create(**validated_data, workspace=workspace)
team_members = [
TeamMember(member=member, team=team, workspace=workspace)
for member in members
]
TeamMember.objects.bulk_create(team_members, batch_size=10)
return team
team = Team.objects.create(**validated_data)
return team
def update(self, instance, validated_data):
if "members" in validated_data:
members = validated_data.pop("members")
TeamMember.objects.filter(team=instance).delete()
team_members = [
TeamMember(
member=member, team=instance, workspace=instance.workspace
)
for member in members
]
TeamMember.objects.bulk_create(team_members, batch_size=10)
return super().update(instance, validated_data)
return super().update(instance, validated_data)
class WorkspaceThemeSerializer(BaseSerializer):
class Meta:
model = WorkspaceTheme
+6
View File
@@ -7,6 +7,7 @@ from plane.app.views import (
ProjectMemberViewSet,
ProjectMemberUserEndpoint,
ProjectJoinEndpoint,
AddTeamToProjectEndpoint,
ProjectUserViewsEndpoint,
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
@@ -115,6 +116,11 @@ urlpatterns = [
),
name="project-member",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/team-invite/",
AddTeamToProjectEndpoint.as_view(),
name="projects",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-views/",
ProjectUserViewsEndpoint.as_view(),
+23
View File
@@ -10,6 +10,7 @@ from plane.app.views import (
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
WorkSpaceAvailabilityCheckEndpoint,
TeamMemberViewSet,
UserLastProjectWithWorkspaceEndpoint,
WorkspaceThemeViewSet,
WorkspaceUserProfileStatsEndpoint,
@@ -126,6 +127,28 @@ urlpatterns = [
),
name="leave-workspace-members",
),
path(
"workspaces/<str:slug>/teams/",
TeamMemberViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="workspace-team-members",
),
path(
"workspaces/<str:slug>/teams/<uuid:pk>/",
TeamMemberViewSet.as_view(
{
"put": "update",
"patch": "partial_update",
"delete": "destroy",
"get": "retrieve",
}
),
name="workspace-team-members",
),
path(
"users/last-visited-workspace/",
UserLastProjectWithWorkspaceEndpoint.as_view(),
+2
View File
@@ -16,6 +16,7 @@ from .project.invite import (
from .project.member import (
ProjectMemberViewSet,
AddTeamToProjectEndpoint,
ProjectMemberUserEndpoint,
UserProjectRolesEndpoint,
)
@@ -48,6 +49,7 @@ from .workspace.favorite import (
from .workspace.member import (
WorkSpaceMemberViewSet,
TeamMemberViewSet,
WorkspaceMemberUserEndpoint,
WorkspaceProjectMemberEndpoint,
WorkspaceMemberUserViewsEndpoint,
+27 -4
View File
@@ -146,7 +146,12 @@ class UserAssetsV2Endpoint(BaseAPIView):
)
# Check if the file type is allowed
allowed_types = ["image/jpeg", "image/png", "image/webp", "image/jpg"]
allowed_types = [
"image/jpeg",
"image/png",
"image/webp",
"image/jpg",
]
if type not in allowed_types:
return Response(
{
@@ -317,7 +322,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
# Project Cover
elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
project = Project.objects.filter(id=asset.workspace_id).first()
project = Project.objects.filter(id=asset.project_id).first()
if project is None:
return
# Delete the previous cover image
@@ -387,7 +392,13 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
)
# Check if the file type is allowed
allowed_types = ["image/jpeg", "image/png", "image/webp", "image/jpg"]
allowed_types = [
"image/jpeg",
"image/png",
"image/webp",
"image/jpg",
"image/gif",
]
if type not in allowed_types:
return Response(
{
@@ -620,7 +631,13 @@ class ProjectAssetEndpoint(BaseAPIView):
)
# Check if the file type is allowed
allowed_types = ["image/jpeg", "image/png", "image/webp", "image/jpg"]
allowed_types = [
"image/jpeg",
"image/png",
"image/webp",
"image/jpg",
"image/gif",
]
if type not in allowed_types:
return Response(
{
@@ -738,6 +755,11 @@ class ProjectAssetEndpoint(BaseAPIView):
class ProjectBulkAssetEndpoint(BaseAPIView):
def save_project_cover(self, asset, project_id):
project = Project.objects.get(id=project_id)
project.cover_image_asset_id = asset.id
project.save()
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def post(self, request, slug, project_id, entity_id):
asset_ids = request.data.get("asset_ids", [])
@@ -773,6 +795,7 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
assets.update(
project_id=project_id,
)
[self.save_project_cover(asset, project_id) for asset in assets]
if asset.entity_type == FileAsset.EntityTypeContext.ISSUE_DESCRIPTION:
assets.update(
@@ -21,6 +21,7 @@ from plane.db.models import (
Project,
ProjectMember,
Workspace,
TeamMember,
IssueUserProperty,
WorkspaceMember,
)
@@ -341,6 +342,56 @@ class ProjectMemberViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class AddTeamToProjectEndpoint(BaseAPIView):
permission_classes = [
ProjectBasePermission,
]
def post(self, request, slug, project_id):
team_members = TeamMember.objects.filter(
workspace__slug=slug, team__in=request.data.get("teams", [])
).values_list("member", flat=True)
if len(team_members) == 0:
return Response(
{"error": "No such team exists"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
project_members = []
issue_props = []
for member in team_members:
project_members.append(
ProjectMember(
project_id=project_id,
member_id=member,
workspace=workspace,
created_by=request.user,
)
)
issue_props.append(
IssueUserProperty(
project_id=project_id,
user_id=member,
workspace=workspace,
created_by=request.user,
)
)
ProjectMember.objects.bulk_create(
project_members, batch_size=10, ignore_conflicts=True
)
_ = IssueUserProperty.objects.bulk_create(
issue_props, batch_size=10, ignore_conflicts=True
)
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class ProjectMemberUserEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
project_member = ProjectMember.objects.get(
-17
View File
@@ -32,7 +32,6 @@ from plane.db.models import (
Session,
)
from plane.license.models import Instance, InstanceAdmin
from plane.utils.cache import cache_response, invalidate_cache
from plane.utils.paginator import BasePaginator
from plane.authentication.utils.host import user_ip
from plane.bgtasks.user_deactivation_email_task import user_deactivation_email
@@ -49,7 +48,6 @@ class UserEndpoint(BaseViewSet):
def get_object(self):
return self.request.user
@cache_response(60 * 60)
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def retrieve(self, request):
@@ -59,14 +57,12 @@ class UserEndpoint(BaseViewSet):
status=status.HTTP_200_OK,
)
@cache_response(60 * 60)
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def retrieve_user_settings(self, request):
serialized_data = UserMeSettingsSerializer(request.user).data
return Response(serialized_data, status=status.HTTP_200_OK)
@cache_response(60 * 60)
def retrieve_instance_admin(self, request):
instance = Instance.objects.first()
is_admin = InstanceAdmin.objects.filter(
@@ -76,19 +72,9 @@ class UserEndpoint(BaseViewSet):
{"is_instance_admin": is_admin}, status=status.HTTP_200_OK
)
@invalidate_cache(
path="/api/users/me/",
)
@invalidate_cache(
path="/api/users/me/settings/",
)
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
@invalidate_cache(path="/api/users/me/")
@invalidate_cache(
path="/api/users/me/workspaces/", multiple=True, user=False
)
def deactivate(self, request):
# Check all workspace user is active
user = self.get_object()
@@ -235,7 +221,6 @@ class UserSessionEndpoint(BaseAPIView):
class UpdateUserOnBoardedEndpoint(BaseAPIView):
@invalidate_cache(path="/api/users/me/")
def patch(self, request):
profile = Profile.objects.get(user_id=request.user.id)
profile.is_onboarded = request.data.get("is_onboarded", False)
@@ -247,7 +232,6 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView):
class UpdateUserTourCompletedEndpoint(BaseAPIView):
@invalidate_cache(path="/api/users/me/")
def patch(self, request):
profile = Profile.objects.get(user_id=request.user.id)
profile.is_tour_completed = request.data.get(
@@ -305,7 +289,6 @@ class ProfileEndpoint(BaseAPIView):
serializer = ProfileSerializer(profile)
return Response(serializer.data, status=status.HTTP_200_OK)
@invalidate_cache("/api/users/me/settings/")
def patch(self, request):
profile = Profile.objects.get(user=request.user)
serializer = ProfileSerializer(
@@ -44,7 +44,6 @@ from plane.db.models import (
WorkspaceTheme,
)
from plane.app.permissions import ROLE, allow_permission
from plane.utils.cache import cache_response, invalidate_cache
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.views.decorators.vary import vary_on_cookie
@@ -99,9 +98,6 @@ class WorkSpaceViewSet(BaseViewSet):
.select_related("owner")
)
@invalidate_cache(path="/api/workspaces/", user=False)
@invalidate_cache(path="/api/users/me/workspaces/")
@invalidate_cache(path="/api/instances/", user=False)
def create(self, request):
try:
serializer = WorkSpaceSerializer(data=request.data)
@@ -147,7 +143,6 @@ class WorkSpaceViewSet(BaseViewSet):
status=status.HTTP_410_GONE,
)
@cache_response(60 * 60 * 2)
@allow_permission(
[
ROLE.ADMIN,
@@ -159,8 +154,6 @@ class WorkSpaceViewSet(BaseViewSet):
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@invalidate_cache(path="/api/workspaces/", user=False)
@invalidate_cache(path="/api/users/me/workspaces/")
@allow_permission(
[
ROLE.ADMIN,
@@ -170,13 +163,6 @@ class WorkSpaceViewSet(BaseViewSet):
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
@invalidate_cache(path="/api/workspaces/", user=False)
@invalidate_cache(
path="/api/users/me/workspaces/", multiple=True, user=False
)
@invalidate_cache(
path="/api/users/me/settings/", multiple=True, user=False
)
@allow_permission([ROLE.ADMIN], level="WORKSPACE")
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
@@ -190,7 +176,6 @@ class UserWorkSpacesEndpoint(BaseAPIView):
"owner",
]
@cache_response(60 * 60 * 2)
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def get(self, request):
+62 -18
View File
@@ -24,6 +24,7 @@ from plane.app.permissions import (
# Module imports
from plane.app.serializers import (
ProjectMemberRoleSerializer,
TeamSerializer,
UserLiteSerializer,
WorkspaceMemberAdminSerializer,
WorkspaceMemberMeSerializer,
@@ -33,12 +34,13 @@ from plane.app.views.base import BaseAPIView
from plane.db.models import (
Project,
ProjectMember,
Team,
User,
Workspace,
WorkspaceMember,
DraftIssue,
)
from plane.utils.cache import cache_response, invalidate_cache
from plane.utils.cache import invalidate_cache
from .. import BaseViewSet
@@ -64,7 +66,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
.select_related("member")
)
@cache_response(60 * 60 * 2)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
@@ -91,12 +92,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
)
return Response(serializer.data, status=status.HTTP_200_OK)
@invalidate_cache(
path="/api/workspaces/:slug/members/",
url_params=True,
user=False,
multiple=True,
)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def partial_update(self, request, slug, pk):
workspace_member = WorkspaceMember.objects.get(
@@ -125,16 +120,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@invalidate_cache(
path="/api/workspaces/:slug/members/",
url_params=True,
user=False,
multiple=True,
)
@invalidate_cache(path="/api/users/me/settings/", multiple=True)
@invalidate_cache(
path="/api/users/me/workspaces/", user=False, multiple=True
)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def destroy(self, request, slug, pk):
# Check the user role who is deleting the user
@@ -350,3 +335,62 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
project_members_dict[str(project_id)].append(project_member)
return Response(project_members_dict, status=status.HTTP_200_OK)
class TeamMemberViewSet(BaseViewSet):
serializer_class = TeamSerializer
model = Team
permission_classes = [
WorkSpaceAdminPermission,
]
search_fields = [
"member__display_name",
"member__first_name",
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "workspace__owner")
.prefetch_related("members")
)
def create(self, request, slug):
members = list(
WorkspaceMember.objects.filter(
workspace__slug=slug,
member__id__in=request.data.get("members", []),
is_active=True,
)
.annotate(member_str_id=Cast("member", output_field=CharField()))
.distinct()
.values_list("member_str_id", flat=True)
)
if len(members) != len(request.data.get("members", [])):
users = list(
set(request.data.get("members", [])).difference(members)
)
users = User.objects.filter(pk__in=users)
serializer = UserLiteSerializer(users, many=True)
return Response(
{
"error": f"{len(users)} of the member(s) are not a part of the workspace",
"members": serializer.data,
},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
serializer = TeamSerializer(
data=request.data, context={"workspace": workspace}
)
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)
+1 -1
View File
@@ -42,7 +42,7 @@ app.conf.beat_schedule = {
},
"run-every-6-hours-for-instance-trace": {
"task": "plane.license.bgtasks.tracer.instance_traces",
"schedule": crontab(hour="*/6"),
"schedule": crontab(hour="*/6", minute=0),
},
}
@@ -1,266 +0,0 @@
# Generated by Django 4.2.15 on 2024-11-18 07:56
from django.db import migrations, models
import plane.db.models.webhook
from django.conf import settings
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
(
"db",
"0085_intake_intakeissue_remove_inboxissue_created_by_and_more",
),
]
operations = [
migrations.AlterUniqueTogether(
name="teammember",
unique_together=None,
),
migrations.RemoveField(
model_name="teammember",
name="created_by",
),
migrations.RemoveField(
model_name="teammember",
name="member",
),
migrations.RemoveField(
model_name="teammember",
name="team",
),
migrations.RemoveField(
model_name="teammember",
name="updated_by",
),
migrations.RemoveField(
model_name="teammember",
name="workspace",
),
migrations.AlterUniqueTogether(
name="teampage",
unique_together=None,
),
migrations.RemoveField(
model_name="teampage",
name="created_by",
),
migrations.RemoveField(
model_name="teampage",
name="page",
),
migrations.RemoveField(
model_name="teampage",
name="team",
),
migrations.RemoveField(
model_name="teampage",
name="updated_by",
),
migrations.RemoveField(
model_name="teampage",
name="workspace",
),
migrations.RemoveField(
model_name="page",
name="teams",
),
migrations.AlterField(
model_name="webhook",
name="url",
field=models.URLField(
max_length=1024,
validators=[
plane.db.models.webhook.validate_schema,
plane.db.models.webhook.validate_domain,
],
),
),
migrations.DeleteModel(
name="Team",
),
migrations.DeleteModel(
name="TeamMember",
),
migrations.DeleteModel(
name="TeamPage",
),
migrations.CreateModel(
name="IssueVersion",
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"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("parent", models.UUIDField(blank=True, null=True)),
("state", models.UUIDField(blank=True, null=True)),
("estimate_point", models.UUIDField(blank=True, null=True)),
(
"name",
models.CharField(
max_length=255, verbose_name="Issue Name"
),
),
("description", models.JSONField(blank=True, default=dict)),
(
"description_html",
models.TextField(blank=True, default="<p></p>"),
),
(
"description_stripped",
models.TextField(blank=True, null=True),
),
("description_binary", models.BinaryField(null=True)),
(
"priority",
models.CharField(
choices=[
("urgent", "Urgent"),
("high", "High"),
("medium", "Medium"),
("low", "Low"),
("none", "None"),
],
default="none",
max_length=30,
verbose_name="Issue Priority",
),
),
("start_date", models.DateField(blank=True, null=True)),
("target_date", models.DateField(blank=True, null=True)),
(
"sequence_id",
models.IntegerField(
default=1, verbose_name="Issue Sequence ID"
),
),
("sort_order", models.FloatField(default=65535)),
("completed_at", models.DateTimeField(null=True)),
("archived_at", models.DateField(null=True)),
("is_draft", models.BooleanField(default=False)),
(
"external_source",
models.CharField(blank=True, max_length=255, null=True),
),
(
"external_id",
models.CharField(blank=True, max_length=255, null=True),
),
("type", models.UUIDField(blank=True, null=True)),
(
"last_saved_at",
models.DateTimeField(default=django.utils.timezone.now),
),
("owned_by", models.UUIDField()),
(
"assignees",
django.contrib.postgres.fields.ArrayField(
base_field=models.UUIDField(),
blank=True,
default=list,
size=None,
),
),
(
"labels",
django.contrib.postgres.fields.ArrayField(
base_field=models.UUIDField(),
blank=True,
default=list,
size=None,
),
),
("cycle", models.UUIDField(blank=True, null=True)),
(
"modules",
django.contrib.postgres.fields.ArrayField(
base_field=models.UUIDField(),
blank=True,
default=list,
size=None,
),
),
("properties", models.JSONField(default=dict)),
("meta", models.JSONField(default=dict)),
(
"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",
),
),
(
"issue",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="versions",
to="db.issue",
),
),
(
"project",
models.ForeignKey(
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",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "Issue Version",
"verbose_name_plural": "Issue Versions",
"db_table": "issue_versions",
"ordering": ("-created_at",),
},
),
]
+2
View File
@@ -77,6 +77,8 @@ from .user import Account, Profile, User
from .view import IssueView
from .webhook import Webhook, WebhookLog
from .workspace import (
Team,
TeamMember,
Workspace,
WorkspaceBaseModel,
WorkspaceMember,
+1 -125
View File
@@ -9,12 +9,11 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction
from django.utils import timezone
from django.db.models import Q
from django import apps
# Module imports
from plane.utils.html_processor import strip_tags
from plane.db.mixins import SoftDeletionManager
from plane.utils.exception_logger import log_exception
from .project import ProjectBaseModel
@@ -710,126 +709,3 @@ class IssueVote(ProjectBaseModel):
def __str__(self):
return f"{self.issue.name} {self.actor.email}"
class IssueVersion(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue",
on_delete=models.CASCADE,
related_name="versions",
)
PRIORITY_CHOICES = (
("urgent", "Urgent"),
("high", "High"),
("medium", "Medium"),
("low", "Low"),
("none", "None"),
)
parent = models.UUIDField(blank=True, null=True)
state = models.UUIDField(blank=True, null=True)
estimate_point = models.UUIDField(blank=True, null=True)
name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True)
priority = models.CharField(
max_length=30,
choices=PRIORITY_CHOICES,
verbose_name="Issue Priority",
default="none",
)
start_date = models.DateField(null=True, blank=True)
target_date = models.DateField(null=True, blank=True)
sequence_id = models.IntegerField(
default=1, verbose_name="Issue Sequence ID"
)
sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
archived_at = models.DateField(null=True)
is_draft = models.BooleanField(default=False)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
type = models.UUIDField(blank=True, null=True)
last_saved_at = models.DateTimeField(default=timezone.now)
owned_by = models.UUIDField()
assignees = ArrayField(
models.UUIDField(),
blank=True,
default=list,
)
labels = ArrayField(
models.UUIDField(),
blank=True,
default=list,
)
cycle = models.UUIDField(
null=True,
blank=True,
)
modules = ArrayField(
models.UUIDField(),
blank=True,
default=list,
)
properties = models.JSONField(default=dict)
meta = models.JSONField(default=dict)
class Meta:
verbose_name = "Issue Version"
verbose_name_plural = "Issue Versions"
db_table = "issue_versions"
ordering = ("-created_at",)
def __str__(self):
return f"{self.name} <{self.project.name}>"
@classmethod
def log_issue_version(cls, issue, user):
try:
"""
Log the issue version
"""
Module = apps.get_model("db.Module")
CycleIssue = apps.get_model("db.CycleIssue")
cycle_issue = CycleIssue.objects.filter(
issue=issue,
).first()
cls.objects.create(
issue=issue,
parent=issue.parent,
state=issue.state,
point=issue.point,
estimate_point=issue.estimate_point,
name=issue.name,
description=issue.description,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_binary=issue.description_binary,
priority=issue.priority,
start_date=issue.start_date,
target_date=issue.target_date,
sequence_id=issue.sequence_id,
sort_order=issue.sort_order,
completed_at=issue.completed_at,
archived_at=issue.archived_at,
is_draft=issue.is_draft,
external_source=issue.external_source,
external_id=issue.external_id,
type=issue.type,
last_saved_at=issue.last_saved_at,
assignees=issue.assignees,
labels=issue.labels,
cycle=cycle_issue.cycle if cycle_issue else None,
modules=Module.objects.filter(issue=issue).values_list(
"id", flat=True
),
owned_by=user,
)
return True
except Exception as e:
log_exception(e)
return False
+29
View File
@@ -52,6 +52,9 @@ class Page(BaseModel):
projects = models.ManyToManyField(
"db.Project", related_name="pages", through="db.ProjectPage"
)
teams = models.ManyToManyField(
"db.Team", related_name="pages", through="db.TeamPage"
)
class Meta:
verbose_name = "Page"
@@ -167,6 +170,32 @@ class ProjectPage(BaseModel):
return f"{self.project.name} {self.page.name}"
class TeamPage(BaseModel):
team = models.ForeignKey(
"db.Team", on_delete=models.CASCADE, related_name="team_pages"
)
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, related_name="team_pages"
)
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="team_pages"
)
class Meta:
unique_together = ["team", "page", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["team", "page"],
condition=models.Q(deleted_at__isnull=True),
name="team_page_unique_team_page_when_deleted_at_null",
)
]
verbose_name = "Team Page"
verbose_name_plural = "Team Pages"
db_table = "team_pages"
ordering = ("-created_at",)
class PageVersion(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
+1 -2
View File
@@ -39,8 +39,7 @@ class Webhook(BaseModel):
validators=[
validate_schema,
validate_domain,
],
max_length=1024,
]
)
is_active = models.BooleanField(default=True)
secret_key = models.CharField(max_length=255, default=generate_token)
+65
View File
@@ -259,6 +259,71 @@ class WorkspaceMemberInvite(BaseModel):
return f"{self.workspace.name} {self.email} {self.accepted}"
class Team(BaseModel):
name = models.CharField(max_length=255, verbose_name="Team Name")
description = models.TextField(verbose_name="Team Description", blank=True)
members = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="members",
through="TeamMember",
through_fields=("team", "member"),
)
workspace = models.ForeignKey(
Workspace, on_delete=models.CASCADE, related_name="workspace_team"
)
logo_props = models.JSONField(default=dict)
def __str__(self):
"""Return name of the team"""
return f"{self.name} <{self.workspace.name}>"
class Meta:
unique_together = ["name", "workspace", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["name", "workspace"],
condition=models.Q(deleted_at__isnull=True),
name="team_unique_name_workspace_when_deleted_at_null",
)
]
verbose_name = "Team"
verbose_name_plural = "Teams"
db_table = "teams"
ordering = ("-created_at",)
class TeamMember(BaseModel):
workspace = models.ForeignKey(
Workspace, on_delete=models.CASCADE, related_name="team_member"
)
team = models.ForeignKey(
Team, on_delete=models.CASCADE, related_name="team_member"
)
member = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="team_member",
)
def __str__(self):
return self.team.name
class Meta:
unique_together = ["team", "member", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["team", "member"],
condition=models.Q(deleted_at__isnull=True),
name="team_member_unique_team_member_when_deleted_at_null",
)
]
verbose_name = "Team Member"
verbose_name_plural = "Team Members"
db_table = "team_members"
ordering = ("-created_at",)
class WorkspaceTheme(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="themes"
@@ -13,7 +13,6 @@ class InstanceSerializer(BaseSerializer):
model = Instance
exclude = [
"license_key",
"user_count"
]
read_only_fields = [
"id",
+20 -1
View File
@@ -3,7 +3,7 @@ from celery import shared_task
from opentelemetry import trace
# Module imports
from plane.license.models import Instance
from plane.license.models import Instance, InstanceAdmin
from plane.db.models import (
User,
Workspace,
@@ -25,6 +25,7 @@ def instance_traces():
# Check if the instance is registered
instance = Instance.objects.first()
instance_admin = InstanceAdmin.objects.first()
# If instance is None then return
if instance is None:
@@ -52,6 +53,17 @@ def instance_traces():
span.set_attribute(
"is_telemetry_enabled", instance.is_telemetry_enabled
)
span.set_attribute(
"is_support_required", instance.is_support_required
)
span.set_attribute("is_setup_done", instance.is_setup_done)
span.set_attribute(
"is_signup_screen_visited", instance.is_signup_screen_visited
)
span.set_attribute("is_verified", instance.is_verified)
span.set_attribute("edition", instance.edition)
span.set_attribute("domain", instance.domain)
span.set_attribute("is_test", instance.is_test)
span.set_attribute("user_count", user_count)
span.set_attribute("workspace_count", workspace_count)
span.set_attribute("project_count", project_count)
@@ -62,6 +74,13 @@ def instance_traces():
span.set_attribute("module_issue_count", module_issue_count)
span.set_attribute("page_count", page_count)
if instance_admin:
span.set_attribute("admin_email", instance_admin.user.email)
span.set_attribute(
"admin_name",
f"{instance_admin.user.first_name} {instance_admin.user.last_name}",
)
# Workspace details
for workspace in Workspace.objects.all():
# Count of all models
@@ -1,6 +1,7 @@
# Python imports
import json
import secrets
import os
# Django imports
from django.core.management.base import BaseCommand, CommandError
@@ -8,10 +9,7 @@ from django.utils import timezone
from django.conf import settings
# Module imports
from plane.license.models import Instance
from plane.db.models import (
User,
)
from plane.license.models import Instance, EditiontTypes
from plane.license.bgtasks.tracer import instance_traces
@@ -32,7 +30,6 @@ class Command(BaseCommand):
payload = {
"instance_key": settings.INSTANCE_KEY,
"version": data.get("version", 0.1),
"user_count": User.objects.filter(is_bot=False).count(),
}
return payload
@@ -54,11 +51,11 @@ class Command(BaseCommand):
instance = Instance.objects.create(
instance_name="Plane Community Edition",
instance_id=secrets.token_hex(12),
license_key=None,
current_version=payload.get("version"),
latest_version=payload.get("version"),
last_checked_at=timezone.now(),
user_count=payload.get("user_count", 0),
is_test=os.environ.get("IS_TEST", "0") == "1",
edition=EditiontTypes.PLANE_CE.value,
)
self.stdout.write(self.style.SUCCESS("Instance registered"))
@@ -69,9 +66,10 @@ class Command(BaseCommand):
payload = self.read_package_json()
# Update the instance details
instance.last_checked_at = timezone.now()
instance.user_count = payload.get("user_count", 0)
instance.current_version = payload.get("version")
instance.latest_version = payload.get("version")
instance.is_test = os.environ.get("IS_TEST", "0") == "1"
instance.edition = EditiontTypes.PLANE_CE.value
instance.save()
# Call the instance traces task
@@ -0,0 +1,31 @@
# Generated by Django 4.2.15 on 2024-11-19 14:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('license', '0004_changelog_deleted_at_instance_deleted_at_and_more'),
]
operations = [
migrations.RenameField(
model_name='instance',
old_name='product',
new_name='edition',
),
migrations.RemoveField(
model_name='instance',
name='license_key',
),
migrations.RemoveField(
model_name='instance',
name='user_count',
),
migrations.AddField(
model_name='instance',
name='is_test',
field=models.BooleanField(default=False),
),
]
+6 -1
View File
@@ -1 +1,6 @@
from .instance import Instance, InstanceAdmin, InstanceConfiguration
from .instance import (
Instance,
InstanceAdmin,
InstanceConfiguration,
EditiontTypes,
)
+4 -6
View File
@@ -11,7 +11,7 @@ from plane.db.models import BaseModel
ROLE_CHOICES = ((20, "Admin"),)
class ProductTypes(Enum):
class EditiontTypes(Enum):
PLANE_CE = "plane-ce"
@@ -20,11 +20,10 @@ class Instance(BaseModel):
instance_name = models.CharField(max_length=255)
whitelist_emails = models.TextField(blank=True, null=True)
instance_id = models.CharField(max_length=255, unique=True)
license_key = models.CharField(max_length=256, null=True, blank=True)
current_version = models.CharField(max_length=255)
latest_version = models.CharField(max_length=255, null=True, blank=True)
product = models.CharField(
max_length=255, default=ProductTypes.PLANE_CE.value
edition = models.CharField(
max_length=255, default=EditiontTypes.PLANE_CE.value
)
domain = models.TextField(blank=True)
# Instance specifics
@@ -37,9 +36,8 @@ class Instance(BaseModel):
is_setup_done = models.BooleanField(default=False)
# signup screen
is_signup_screen_visited = models.BooleanField(default=False)
# users
user_count = models.PositiveBigIntegerField(default=0)
is_verified = models.BooleanField(default=False)
is_test = models.BooleanField(default=False)
class Meta:
verbose_name = "Instance"
+5 -1
View File
@@ -18,7 +18,11 @@ export interface IProject {
close_in: number;
created_at: Date;
created_by: string;
cover_image_url: string;
// only for uploading the cover image
cover_image_asset?: null;
cover_image?: string;
// only for rendering the cover image
cover_image_url: readonly string;
cycle_view: boolean;
issue_views_view: boolean;
module_view: boolean;
+13 -4
View File
@@ -3,7 +3,6 @@ import { TUserPermissions } from "./enums";
type TLoginMediums = "email" | "magic-code" | "github" | "gitlab" | "google";
export interface IUserLite {
avatar_url: string;
display_name: string;
@@ -14,7 +13,11 @@ export interface IUserLite {
last_name: string;
}
export interface IUser extends IUserLite {
cover_image_url: string | null;
// only for uploading the cover image
cover_image_asset?: string | null;
cover_image?: string | null;
// only for rendering the cover image
cover_image_url: readonly (string | null);
date_joined: string;
email: string;
is_active: boolean;
@@ -90,7 +93,6 @@ export interface IUserTheme {
sidebarBackground: string | undefined;
}
export interface IUserMemberLite extends IUserLite {
email?: string;
}
@@ -153,7 +155,14 @@ export interface IUserProfileProjectSegregation {
id: string;
pending_issues: number;
}[];
user_data: Pick<IUser, "avatar_url" | "cover_image_url" | "display_name" | "first_name" | "last_name"> & {
user_data: Pick<
IUser,
| "avatar_url"
| "cover_image_url"
| "display_name"
| "first_name"
| "last_name"
> & {
date_joined: Date;
user_timezone: string;
};
@@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useCallback, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
@@ -62,6 +62,11 @@ const WorkspaceDashboardPage = observer(() => {
workspace_slug && project_id && is_inbox_issue ? () => fetchUserProjectInfo(workspace_slug, project_id) : null
);
const embedRemoveCurrentNotification = useCallback(
() => setCurrentSelectedNotificationId(undefined),
[setCurrentSelectedNotificationId]
);
// clearing up the selected notifications when unmounting the page
useEffect(
() => () => {
@@ -95,15 +100,12 @@ const WorkspaceDashboardPage = observer(() => {
projectId={project_id}
inboxIssueId={issue_id}
isNotificationEmbed
embedRemoveCurrentNotification={() => setCurrentSelectedNotificationId(undefined)}
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
/>
)}
</>
) : (
<IssuePeekOverview
embedIssue
embedRemoveCurrentNotification={() => setCurrentSelectedNotificationId(undefined)}
/>
<IssuePeekOverview embedIssue embedRemoveCurrentNotification={embedRemoveCurrentNotification} />
)}
</>
)}
+5 -1
View File
@@ -70,11 +70,15 @@ const ProfileSettingsPage = observer(() => {
first_name: formData.first_name,
last_name: formData.last_name,
avatar_url: formData.avatar_url,
cover_image_url: formData.cover_image_url,
role: formData.role,
display_name: formData?.display_name,
user_timezone: formData.user_timezone,
};
// if unsplash or a pre-defined image is uploaded, delete the old uploaded asset
if (formData.cover_image_url?.startsWith("http")) {
payload.cover_image = formData.cover_image_url;
payload.cover_image_asset = null;
}
const updateCurrentUserDetail = updateCurrentUser(payload).finally(() => setIsLoading(false));
setPromiseToast(updateCurrentUserDetail, {
@@ -74,6 +74,11 @@ export const CreateProjectForm: FC<TCreateProjectFormProps> = observer((props) =
// Upper case identifier
formData.identifier = formData.identifier?.toUpperCase();
const coverImage = formData.cover_image_url;
// if unsplash or a pre-defined image is uploaded, delete the old uploaded asset
if (coverImage?.startsWith("http")) {
formData.cover_image = coverImage;
formData.cover_image_asset = null;
}
return createProject(workspaceSlug.toString(), formData)
.then(async (res) => {
+6 -1
View File
@@ -144,10 +144,15 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
network: formData.network,
identifier: formData.identifier,
description: formData.description,
cover_image_url: formData.cover_image_url,
logo_props: formData.logo_props,
// timezone: formData.timezone,
};
// if unsplash or a pre-defined image is uploaded, delete the old uploaded asset
if (formData.cover_image_url?.startsWith("http")) {
payload.cover_image = formData.cover_image_url;
payload.cover_image_asset = null;
}
if (project.identifier !== formData.identifier)
await projectService