/archived-issues/",
diff --git a/apps/api/plane/app/urls/module.py b/apps/api/plane/app/urls/module.py
index 75cbb14d6b6..255f8211ccc 100644
--- a/apps/api/plane/app/urls/module.py
+++ b/apps/api/plane/app/urls/module.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
diff --git a/apps/api/plane/app/urls/notification.py b/apps/api/plane/app/urls/notification.py
index 0c992d49e98..cd2b3c5a4e8 100644
--- a/apps/api/plane/app/urls/notification.py
+++ b/apps/api/plane/app/urls/notification.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
diff --git a/apps/api/plane/app/urls/page.py b/apps/api/plane/app/urls/page.py
index 8cac22a2fd8..dd4395c18e7 100644
--- a/apps/api/plane/app/urls/page.py
+++ b/apps/api/plane/app/urls/page.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
diff --git a/apps/api/plane/app/urls/project.py b/apps/api/plane/app/urls/project.py
index a6dd8d8a8a9..ee850af1d68 100644
--- a/apps/api/plane/app/urls/project.py
+++ b/apps/api/plane/app/urls/project.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
from plane.app.views import (
diff --git a/apps/api/plane/app/urls/search.py b/apps/api/plane/app/urls/search.py
index 0bbbd9cf7f4..9d94aa27377 100644
--- a/apps/api/plane/app/urls/search.py
+++ b/apps/api/plane/app/urls/search.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
diff --git a/apps/api/plane/app/urls/state.py b/apps/api/plane/app/urls/state.py
index b6135ca9596..902b583cb5f 100644
--- a/apps/api/plane/app/urls/state.py
+++ b/apps/api/plane/app/urls/state.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
diff --git a/apps/api/plane/app/urls/timezone.py b/apps/api/plane/app/urls/timezone.py
index ff14d029f2e..9fc17f79aad 100644
--- a/apps/api/plane/app/urls/timezone.py
+++ b/apps/api/plane/app/urls/timezone.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
from plane.app.views import TimezoneEndpoint
diff --git a/apps/api/plane/app/urls/user.py b/apps/api/plane/app/urls/user.py
index 373d4a70d39..bc110a28dd9 100644
--- a/apps/api/plane/app/urls/user.py
+++ b/apps/api/plane/app/urls/user.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
from plane.app.views import (
diff --git a/apps/api/plane/app/urls/views.py b/apps/api/plane/app/urls/views.py
index 063e39c3db4..f3e4ee1de5c 100644
--- a/apps/api/plane/app/urls/views.py
+++ b/apps/api/plane/app/urls/views.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
diff --git a/apps/api/plane/app/urls/webhook.py b/apps/api/plane/app/urls/webhook.py
index e21ec726143..22ac4bc6f81 100644
--- a/apps/api/plane/app/urls/webhook.py
+++ b/apps/api/plane/app/urls/webhook.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
from plane.app.views import (
diff --git a/apps/api/plane/app/urls/workspace.py b/apps/api/plane/app/urls/workspace.py
index 5f781efa7a6..d79d5a74522 100644
--- a/apps/api/plane/app/urls/workspace.py
+++ b/apps/api/plane/app/urls/workspace.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py
index 7a0e5cb3a28..84f7872ec85 100644
--- a/apps/api/plane/app/views/__init__.py
+++ b/apps/api/plane/app/views/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from .project.base import (
ProjectViewSet,
ProjectIdentifierEndpoint,
@@ -114,7 +118,7 @@
from .issue.base import (
IssueListEndpoint,
IssueViewSet,
- IssueUserDisplayPropertyEndpoint,
+ ProjectUserDisplayPropertyEndpoint,
BulkDeleteIssuesEndpoint,
DeletedIssuesListViewSet,
IssuePaginatedViewSet,
@@ -161,7 +165,7 @@
from .module.archive import ModuleArchiveUnarchiveEndpoint
-from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint
+from .api import ApiTokenEndpoint
from .page.base import (
PageViewSet,
diff --git a/apps/api/plane/app/views/analytic/advance.py b/apps/api/plane/app/views/analytic/advance.py
index 1a5b1b34ceb..5ba9a439b45 100644
--- a/apps/api/plane/app/views/analytic/advance.py
+++ b/apps/api/plane/app/views/analytic/advance.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from rest_framework.response import Response
from rest_framework import status
from typing import Dict, List, Any
diff --git a/apps/api/plane/app/views/analytic/base.py b/apps/api/plane/app/views/analytic/base.py
index 6e9311a1853..2f3f8b5737d 100644
--- a/apps/api/plane/app/views/analytic/base.py
+++ b/apps/api/plane/app/views/analytic/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.db.models import Count, F, Sum, Q
from django.db.models.functions import ExtractMonth
diff --git a/apps/api/plane/app/views/analytic/project_analytics.py b/apps/api/plane/app/views/analytic/project_analytics.py
index 2529900b05a..c8e896716b5 100644
--- a/apps/api/plane/app/views/analytic/project_analytics.py
+++ b/apps/api/plane/app/views/analytic/project_analytics.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from rest_framework.response import Response
from rest_framework import status
from typing import Dict, Any
diff --git a/apps/api/plane/app/views/api.py b/apps/api/plane/app/views/api.py
index 41985990239..f3163c33146 100644
--- a/apps/api/plane/app/views/api.py
+++ b/apps/api/plane/app/views/api.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python import
from uuid import uuid4
from typing import Optional
@@ -9,9 +13,8 @@
# Module import
from .base import BaseAPIView
-from plane.db.models import APIToken, Workspace
+from plane.db.models import APIToken
from plane.app.serializers import APITokenSerializer, APITokenReadSerializer
-from plane.app.permissions import WorkspaceEntityPermission
class ApiTokenEndpoint(BaseAPIView):
@@ -57,28 +60,3 @@ def patch(self, request: Request, pk: str) -> Response:
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
-
-
-class ServiceApiTokenEndpoint(BaseAPIView):
- permission_classes = [WorkspaceEntityPermission]
-
- def post(self, request: Request, slug: str) -> Response:
- workspace = Workspace.objects.get(slug=slug)
-
- api_token = APIToken.objects.filter(workspace=workspace, is_service=True).first()
-
- if api_token:
- return Response({"token": str(api_token.token)}, status=status.HTTP_200_OK)
- else:
- # Check the user type
- user_type = 1 if request.user.is_bot else 0
-
- api_token = APIToken.objects.create(
- label=str(uuid4().hex),
- description="Service Token",
- user=request.user,
- workspace=workspace,
- user_type=user_type,
- is_service=True,
- )
- return Response({"token": str(api_token.token)}, status=status.HTTP_201_CREATED)
diff --git a/apps/api/plane/app/views/asset/base.py b/apps/api/plane/app/views/asset/base.py
index 522d4af7518..5b55a76a611 100644
--- a/apps/api/plane/app/views/asset/base.py
+++ b/apps/api/plane/app/views/asset/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third party imports
from rest_framework import status
from rest_framework.response import Response
diff --git a/apps/api/plane/app/views/asset/v2.py b/apps/api/plane/app/views/asset/v2.py
index 3677537bb1e..62c5f84a20b 100644
--- a/apps/api/plane/app/views/asset/v2.py
+++ b/apps/api/plane/app/views/asset/v2.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import uuid
@@ -45,9 +49,7 @@ def entity_asset_save(self, asset_id, entity_type, asset, request):
# Save the new avatar
user.avatar_asset_id = asset_id
user.save()
- invalidate_cache_directly(
- path="/api/users/me/", url_params=False, user=True, request=request
- )
+ invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
@@ -65,9 +67,7 @@ def entity_asset_save(self, asset_id, entity_type, asset, request):
# Save the new cover image
user.cover_image_asset_id = asset_id
user.save()
- invalidate_cache_directly(
- path="/api/users/me/", url_params=False, user=True, request=request
- )
+ invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
@@ -83,9 +83,7 @@ def entity_asset_delete(self, entity_type, asset, request):
user = User.objects.get(id=asset.user_id)
user.avatar_asset_id = None
user.save()
- invalidate_cache_directly(
- path="/api/users/me/", url_params=False, user=True, request=request
- )
+ invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
@@ -98,9 +96,7 @@ def entity_asset_delete(self, entity_type, asset, request):
user = User.objects.get(id=asset.user_id)
user.cover_image_asset_id = None
user.save()
- invalidate_cache_directly(
- path="/api/users/me/", url_params=False, user=True, request=request
- )
+ invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
@@ -160,9 +156,7 @@ def post(self, request):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
- presigned_url = storage.generate_presigned_post(
- object_name=asset_key, file_type=type, file_size=size_limit
- )
+ presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
# Return the presigned URL
return Response(
{
@@ -199,9 +193,7 @@ def delete(self, request, asset_id):
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
- self.entity_asset_delete(
- entity_type=asset.entity_type, asset=asset, request=request
- )
+ self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -265,18 +257,14 @@ def entity_asset_save(self, asset_id, entity_type, asset, request):
workspace.logo = ""
workspace.logo_asset_id = asset_id
workspace.save()
- invalidate_cache_directly(
- path="/api/workspaces/", url_params=False, user=False, request=request
- )
+ invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request)
invalidate_cache_directly(
path="/api/users/me/workspaces/",
url_params=False,
user=True,
request=request,
)
- invalidate_cache_directly(
- path="/api/instances/", url_params=False, user=False, request=request
- )
+ invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request)
return
# Project Cover
@@ -303,18 +291,14 @@ def entity_asset_delete(self, entity_type, asset, request):
return
workspace.logo_asset_id = None
workspace.save()
- invalidate_cache_directly(
- path="/api/workspaces/", url_params=False, user=False, request=request
- )
+ invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request)
invalidate_cache_directly(
path="/api/users/me/workspaces/",
url_params=False,
user=True,
request=request,
)
- invalidate_cache_directly(
- path="/api/instances/", url_params=False, user=False, request=request
- )
+ invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request)
return
# Project Cover
elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
@@ -375,17 +359,13 @@ def post(self, request, slug):
workspace=workspace,
created_by=request.user,
entity_type=entity_type,
- **self.get_entity_id_field(
- entity_type=entity_type, entity_id=entity_identifier
- ),
+ **self.get_entity_id_field(entity_type=entity_type, entity_id=entity_identifier),
)
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
- presigned_url = storage.generate_presigned_post(
- object_name=asset_key, file_type=type, file_size=size_limit
- )
+ presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
# Return the presigned URL
return Response(
{
@@ -422,9 +402,7 @@ def delete(self, request, slug, asset_id):
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
- self.entity_asset_delete(
- entity_type=asset.entity_type, asset=asset, request=request
- )
+ self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -587,9 +565,7 @@ def post(self, request, slug, project_id):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
- presigned_url = storage.generate_presigned_post(
- object_name=asset_key, file_type=type, file_size=size_limit
- )
+ presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
# Return the presigned URL
return Response(
{
@@ -619,9 +595,7 @@ def patch(self, request, slug, project_id, pk):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def delete(self, request, slug, project_id, pk):
# Get the asset
- asset = FileAsset.objects.get(
- id=pk, workspace__slug=slug, project_id=project_id
- )
+ asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
# Check deleted assets
asset.is_deleted = True
asset.deleted_at = timezone.now()
@@ -632,9 +606,7 @@ def delete(self, request, slug, project_id, pk):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, pk):
# get the asset id
- asset = FileAsset.objects.get(
- workspace__slug=slug, project_id=project_id, pk=pk
- )
+ asset = FileAsset.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
# Check if the asset is uploaded
if not asset.is_uploaded:
@@ -667,9 +639,7 @@ def post(self, request, slug, project_id, entity_id):
# Check if the asset ids are provided
if not asset_ids:
- return Response(
- {"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST
- )
+ return Response({"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST)
# get the asset id
assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug)
@@ -723,14 +693,11 @@ class AssetCheckEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug, asset_id):
- asset = FileAsset.all_objects.filter(
- id=asset_id, workspace__slug=slug, deleted_at__isnull=True
- ).exists()
+ asset = FileAsset.all_objects.filter(id=asset_id, workspace__slug=slug, deleted_at__isnull=True).exists()
return Response({"exists": asset}, status=status.HTTP_200_OK)
class DuplicateAssetEndpoint(BaseAPIView):
-
throttle_classes = [AssetRateThrottle]
def get_entity_id_field(self, entity_type, entity_id):
@@ -766,17 +733,13 @@ def get_entity_id_field(self, entity_type, entity_id):
return {}
- @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
+ @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def post(self, request, slug, asset_id):
project_id = request.data.get("project_id", None)
entity_id = request.data.get("entity_id", None)
entity_type = request.data.get("entity_type", None)
-
- if (
- not entity_type
- or entity_type not in FileAsset.EntityTypeContext.values
- ):
+ if not entity_type or entity_type not in FileAsset.EntityTypeContext.values:
return Response(
{"error": "Invalid entity type or entity id"},
status=status.HTTP_400_BAD_REQUEST,
@@ -786,23 +749,15 @@ def post(self, request, slug, asset_id):
if project_id:
# check if project exists in the workspace
if not Project.objects.filter(id=project_id, workspace=workspace).exists():
- return Response(
- {"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND
- )
+ return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
storage = S3Storage(request=request)
- original_asset = FileAsset.objects.filter(
- workspace=workspace, id=asset_id, is_uploaded=True
- ).first()
+ original_asset = FileAsset.objects.filter(id=asset_id, is_uploaded=True).first()
if not original_asset:
- return Response(
- {"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
- )
+ return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
- destination_key = (
- f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
- )
+ destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
duplicated_asset = FileAsset.objects.create(
attributes={
"name": original_asset.attributes.get("name"),
@@ -822,9 +777,7 @@ def post(self, request, slug, asset_id):
# Update the is_uploaded field for all newly created assets
FileAsset.objects.filter(id=duplicated_asset.id).update(is_uploaded=True)
- return Response(
- {"asset_id": str(duplicated_asset.id)}, status=status.HTTP_200_OK
- )
+ return Response({"asset_id": str(duplicated_asset.id)}, status=status.HTTP_200_OK)
class WorkspaceAssetDownloadEndpoint(BaseAPIView):
diff --git a/apps/api/plane/app/views/base.py b/apps/api/plane/app/views/base.py
index 0323302c5a3..db5469de585 100644
--- a/apps/api/plane/app/views/base.py
+++ b/apps/api/plane/app/views/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import traceback
diff --git a/apps/api/plane/app/views/cycle/archive.py b/apps/api/plane/app/views/cycle/archive.py
index a2f89d53f6c..3738b336717 100644
--- a/apps/api/plane/app/views/cycle/archive.py
+++ b/apps/api/plane/app/views/cycle/archive.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
diff --git a/apps/api/plane/app/views/cycle/base.py b/apps/api/plane/app/views/cycle/base.py
index 712d71754e5..30a5732ce0a 100644
--- a/apps/api/plane/app/views/cycle/base.py
+++ b/apps/api/plane/app/views/cycle/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
import pytz
@@ -97,9 +101,7 @@ def get_queryset(self):
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
- queryset=User.objects.only(
- "avatar_asset", "first_name", "id"
- ).distinct(),
+ queryset=User.objects.only("avatar_asset", "first_name", "id").distinct(),
)
)
.prefetch_related(
@@ -150,8 +152,7 @@ def get_queryset(self):
.annotate(
status=Case(
When(
- Q(start_date__lte=current_time_in_utc)
- & Q(end_date__gte=current_time_in_utc),
+ Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc),
then=Value("CURRENT"),
),
When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")),
@@ -170,11 +171,7 @@ def get_queryset(self):
"issue_cycle__issue__assignees__id",
distinct=True,
filter=~Q(issue_cycle__issue__assignees__id__isnull=True)
- & (
- Q(
- issue_cycle__issue__issue_assignee__deleted_at__isnull=True
- )
- ),
+ & (Q(issue_cycle__issue__issue_assignee__deleted_at__isnull=True)),
),
Value([], output_field=ArrayField(UUIDField())),
)
@@ -205,9 +202,7 @@ def list(self, request, slug, project_id):
# Current Cycle
if cycle_view == "current":
- queryset = queryset.filter(
- start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc
- )
+ queryset = queryset.filter(start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc)
data = queryset.values(
# necessary fields
@@ -274,16 +269,10 @@ def list(self, request, slug, project_id):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
- if (
- request.data.get("start_date", None) is None
- and request.data.get("end_date", None) is None
- ) or (
- request.data.get("start_date", None) is not None
- and request.data.get("end_date", None) is not None
+ if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
+ request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
):
- serializer = CycleWriteSerializer(
- data=request.data, context={"project_id": project_id}
- )
+ serializer = CycleWriteSerializer(data=request.data, context={"project_id": project_id})
if serializer.is_valid():
serializer.save(project_id=project_id, owned_by=request.user)
cycle = (
@@ -323,9 +312,7 @@ def create(self, request, slug, project_id):
project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"]
- cycle = user_timezone_converter(
- cycle, datetime_fields, project_timezone
- )
+ cycle = user_timezone_converter(cycle, datetime_fields, project_timezone)
# Send the model activity
model_activity.delay(
@@ -341,17 +328,13 @@ def create(self, request, slug, project_id):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
- {
- "error": "Both start date and end date are either required or are to be null"
- },
+ {"error": "Both start date and end date are either required or are to be null"},
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
- queryset = self.get_queryset().filter(
- workspace__slug=slug, project_id=project_id, pk=pk
- )
+ queryset = self.get_queryset().filter(workspace__slug=slug, project_id=project_id, pk=pk)
cycle = queryset.first()
if cycle.archived_at:
return Response(
@@ -359,29 +342,21 @@ def partial_update(self, request, slug, project_id, pk):
status=status.HTTP_400_BAD_REQUEST,
)
- current_instance = json.dumps(
- CycleSerializer(cycle).data, cls=DjangoJSONEncoder
- )
+ current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
request_data = request.data
if cycle.end_date is not None and cycle.end_date < timezone.now():
if "sort_order" in request_data:
# Can only change sort order for a completed cycle``
- request_data = {
- "sort_order": request_data.get("sort_order", cycle.sort_order)
- }
+ request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
else:
return Response(
- {
- "error": "The Cycle has already been completed so it cannot be edited"
- },
+ {"error": "The Cycle has already been completed so it cannot be edited"},
status=status.HTTP_400_BAD_REQUEST,
)
- serializer = CycleWriteSerializer(
- cycle, data=request.data, partial=True, context={"project_id": project_id}
- )
+ serializer = CycleWriteSerializer(cycle, data=request.data, partial=True, context={"project_id": project_id})
if serializer.is_valid():
serializer.save()
cycle = queryset.values(
@@ -481,9 +456,7 @@ def retrieve(self, request, slug, project_id, pk):
)
if data is None:
- return Response(
- {"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
- )
+ return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
queryset = queryset.first()
# Fetch the project timezone
@@ -505,11 +478,7 @@ def retrieve(self, request, slug, project_id, pk):
def destroy(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
- cycle_issues = list(
- CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
- "issue", flat=True
- )
- )
+ cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
issue_activity.delay(
type="cycle.activity.deleted",
@@ -560,9 +529,7 @@ def post(self, request, slug, project_id):
status=status.HTTP_400_BAD_REQUEST,
)
- start_date = convert_to_utc(
- date=str(start_date), project_id=project_id, is_start_date=True
- )
+ start_date = convert_to_utc(date=str(start_date), project_id=project_id, is_start_date=True)
end_date = convert_to_utc(
date=str(end_date),
project_id=project_id,
@@ -666,12 +633,8 @@ def patch(self, request, slug, project_id, cycle_id):
)
cycle_properties.filters = request.data.get("filters", cycle_properties.filters)
- cycle_properties.rich_filters = request.data.get(
- "rich_filters", cycle_properties.rich_filters
- )
- cycle_properties.display_filters = request.data.get(
- "display_filters", cycle_properties.display_filters
- )
+ cycle_properties.rich_filters = request.data.get("rich_filters", cycle_properties.rich_filters)
+ cycle_properties.display_filters = request.data.get("display_filters", cycle_properties.display_filters)
cycle_properties.display_properties = request.data.get(
"display_properties", cycle_properties.display_properties
)
@@ -695,13 +658,9 @@ def get(self, request, slug, project_id, cycle_id):
class CycleProgressEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, cycle_id):
- cycle = Cycle.objects.filter(
- workspace__slug=slug, project_id=project_id, id=cycle_id
- ).first()
+ cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id).first()
if not cycle:
- return Response(
- {"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
- )
+ return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
aggregate_estimates = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
@@ -747,9 +706,7 @@ def get(self, request, slug, project_id, cycle_id):
output_field=FloatField(),
)
),
- total_estimate_points=Sum(
- "value_as_float", default=Value(0), output_field=FloatField()
- ),
+ total_estimate_points=Sum("value_as_float", default=Value(0), output_field=FloatField()),
)
)
if cycle.progress_snapshot:
@@ -809,22 +766,11 @@ def get(self, request, slug, project_id, cycle_id):
return Response(
{
- "backlog_estimate_points": aggregate_estimates["backlog_estimate_point"]
- or 0,
- "unstarted_estimate_points": aggregate_estimates[
- "unstarted_estimate_point"
- ]
- or 0,
- "started_estimate_points": aggregate_estimates["started_estimate_point"]
- or 0,
- "cancelled_estimate_points": aggregate_estimates[
- "cancelled_estimate_point"
- ]
- or 0,
- "completed_estimate_points": aggregate_estimates[
- "completed_estimate_points"
- ]
- or 0,
+ "backlog_estimate_points": aggregate_estimates["backlog_estimate_point"] or 0,
+ "unstarted_estimate_points": aggregate_estimates["unstarted_estimate_point"] or 0,
+ "started_estimate_points": aggregate_estimates["started_estimate_point"] or 0,
+ "cancelled_estimate_points": aggregate_estimates["cancelled_estimate_point"] or 0,
+ "completed_estimate_points": aggregate_estimates["completed_estimate_points"] or 0,
"total_estimate_points": aggregate_estimates["total_estimate_points"],
"backlog_issues": backlog_issues,
"total_issues": total_issues,
@@ -842,9 +788,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
def get(self, request, slug, project_id, cycle_id):
analytic_type = request.GET.get("type", "issues")
cycle = (
- Cycle.objects.filter(
- workspace__slug=slug, project_id=project_id, id=cycle_id
- )
+ Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id)
.annotate(
total_issues=Count(
"issue_cycle__issue__id",
@@ -927,9 +871,7 @@ def get(self, request, slug, project_id, cycle_id):
)
)
.values("display_name", "assignee_id", "avatar_url")
- .annotate(
- total_estimates=Sum(Cast("estimate_point__value", FloatField()))
- )
+ .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
@@ -964,9 +906,7 @@ def get(self, request, slug, project_id, cycle_id):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
- .annotate(
- total_estimates=Sum(Cast("estimate_point__value", FloatField()))
- )
+ .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
@@ -1068,11 +1008,7 @@ def get(self, request, slug, project_id, cycle_id):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
- .annotate(
- total_issues=Count(
- "label_id", filter=Q(archived_at__isnull=True, is_draft=False)
- )
- )
+ .annotate(total_issues=Count("label_id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"label_id",
diff --git a/apps/api/plane/app/views/cycle/issue.py b/apps/api/plane/app/views/cycle/issue.py
index ad3923b17bd..60996784578 100644
--- a/apps/api/plane/app/views/cycle/issue.py
+++ b/apps/api/plane/app/views/cycle/issue.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import copy
import json
diff --git a/apps/api/plane/app/views/error_404.py b/apps/api/plane/app/views/error_404.py
index 97c3c59f7cc..b7ec4dcf3a5 100644
--- a/apps/api/plane/app/views/error_404.py
+++ b/apps/api/plane/app/views/error_404.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# views.py
from django.http import JsonResponse
diff --git a/apps/api/plane/app/views/estimate/base.py b/apps/api/plane/app/views/estimate/base.py
index f54115a4f2e..4bdc86633d6 100644
--- a/apps/api/plane/app/views/estimate/base.py
+++ b/apps/api/plane/app/views/estimate/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import random
import string
import json
diff --git a/apps/api/plane/app/views/exporter/base.py b/apps/api/plane/app/views/exporter/base.py
index 5f446ff9495..64364ecf470 100644
--- a/apps/api/plane/app/views/exporter/base.py
+++ b/apps/api/plane/app/views/exporter/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third Party imports
from rest_framework import status
from rest_framework.response import Response
diff --git a/apps/api/plane/app/views/external/base.py b/apps/api/plane/app/views/external/base.py
index 2c554bbc866..013bad2dbf6 100644
--- a/apps/api/plane/app/views/external/base.py
+++ b/apps/api/plane/app/views/external/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python import
import os
from typing import List, Dict, Tuple
diff --git a/apps/api/plane/app/views/intake/base.py b/apps/api/plane/app/views/intake/base.py
index 7dd7828cbdf..d4049aa3cb2 100644
--- a/apps/api/plane/app/views/intake/base.py
+++ b/apps/api/plane/app/views/intake/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
@@ -394,7 +398,7 @@ def partial_update(self, request, slug, project_id, pk):
issue_data = {
"name": issue_data.get("name", issue.name),
"description_html": issue_data.get("description_html", issue.description_html),
- "description": issue_data.get("description", issue.description),
+ "description_json": issue_data.get("description_json", issue.description_json),
}
issue_current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
diff --git a/apps/api/plane/app/views/issue/activity.py b/apps/api/plane/app/views/issue/activity.py
index fdfcd129ae6..8f629564013 100644
--- a/apps/api/plane/app/views/issue/activity.py
+++ b/apps/api/plane/app/views/issue/activity.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
from itertools import chain
diff --git a/apps/api/plane/app/views/issue/archive.py b/apps/api/plane/app/views/issue/archive.py
index b8f8589696a..1ac808cf926 100644
--- a/apps/api/plane/app/views/issue/archive.py
+++ b/apps/api/plane/app/views/issue/archive.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import copy
import json
diff --git a/apps/api/plane/app/views/issue/attachment.py b/apps/api/plane/app/views/issue/attachment.py
index 2207d241929..df027c413b1 100644
--- a/apps/api/plane/app/views/issue/attachment.py
+++ b/apps/api/plane/app/views/issue/attachment.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
import uuid
diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py
index 7a5e7dddf62..bb331802c84 100644
--- a/apps/api/plane/app/views/issue/base.py
+++ b/apps/api/plane/app/views/issue/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import copy
import json
@@ -34,7 +38,7 @@
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 +55,7 @@
IssueReaction,
IssueRelation,
IssueSubscriber,
- IssueUserProperty,
+ ProjectUserProperty,
ModuleIssue,
Project,
ProjectMember,
@@ -723,23 +727,33 @@ def destroy(self, request, slug, project_id, pk=None):
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)
@@ -1104,7 +1118,7 @@ def post(self, request, slug, project_id):
epoch = int(timezone.now().timestamp())
# Fetch all relevant issues in a single query
- issues = list(Issue.objects.filter(id__in=issue_ids))
+ issues = list(Issue.objects.filter(id__in=issue_ids, workspace__slug=slug, project_id=project_id))
issues_dict = {str(issue.id): issue for issue in issues}
issues_to_update = []
diff --git a/apps/api/plane/app/views/issue/comment.py b/apps/api/plane/app/views/issue/comment.py
index 72a986fea55..34fe0f9e4b9 100644
--- a/apps/api/plane/app/views/issue/comment.py
+++ b/apps/api/plane/app/views/issue/comment.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
diff --git a/apps/api/plane/app/views/issue/label.py b/apps/api/plane/app/views/issue/label.py
index ad0a290801b..05033593e60 100644
--- a/apps/api/plane/app/views/issue/label.py
+++ b/apps/api/plane/app/views/issue/label.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import random
@@ -39,9 +43,7 @@ def get_queryset(self):
@allow_permission([ROLE.ADMIN])
def create(self, request, slug, project_id):
try:
- serializer = LabelSerializer(
- data=request.data, context={"project_id": project_id}
- )
+ serializer = LabelSerializer(data=request.data, context={"project_id": project_id})
if serializer.is_valid():
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
diff --git a/apps/api/plane/app/views/issue/link.py b/apps/api/plane/app/views/issue/link.py
index ee209f9fae8..54902123026 100644
--- a/apps/api/plane/app/views/issue/link.py
+++ b/apps/api/plane/app/views/issue/link.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
diff --git a/apps/api/plane/app/views/issue/reaction.py b/apps/api/plane/app/views/issue/reaction.py
index fe8a63b1355..c09e1e92442 100644
--- a/apps/api/plane/app/views/issue/reaction.py
+++ b/apps/api/plane/app/views/issue/reaction.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
diff --git a/apps/api/plane/app/views/issue/relation.py b/apps/api/plane/app/views/issue/relation.py
index 0dfd686d29e..e91ddffec81 100644
--- a/apps/api/plane/app/views/issue/relation.py
+++ b/apps/api/plane/app/views/issue/relation.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
diff --git a/apps/api/plane/app/views/issue/sub_issue.py b/apps/api/plane/app/views/issue/sub_issue.py
index 2fa244dcfb6..b52e07564f6 100644
--- a/apps/api/plane/app/views/issue/sub_issue.py
+++ b/apps/api/plane/app/views/issue/sub_issue.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
diff --git a/apps/api/plane/app/views/issue/subscriber.py b/apps/api/plane/app/views/issue/subscriber.py
index 58f3ba4c7eb..c9a1a29b658 100644
--- a/apps/api/plane/app/views/issue/subscriber.py
+++ b/apps/api/plane/app/views/issue/subscriber.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
diff --git a/apps/api/plane/app/views/issue/version.py b/apps/api/plane/app/views/issue/version.py
index 358271ac875..540c7d6d5c7 100644
--- a/apps/api/plane/app/views/issue/version.py
+++ b/apps/api/plane/app/views/issue/version.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third party imports
from rest_framework import status
from rest_framework.response import Response
diff --git a/apps/api/plane/app/views/module/archive.py b/apps/api/plane/app/views/module/archive.py
index 129ff0dac49..1f234d79156 100644
--- a/apps/api/plane/app/views/module/archive.py
+++ b/apps/api/plane/app/views/module/archive.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import (
diff --git a/apps/api/plane/app/views/module/base.py b/apps/api/plane/app/views/module/base.py
index ae429e7504b..97e683f7508 100644
--- a/apps/api/plane/app/views/module/base.py
+++ b/apps/api/plane/app/views/module/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
diff --git a/apps/api/plane/app/views/module/issue.py b/apps/api/plane/app/views/module/issue.py
index 672bf4e1ae9..4707d683a7d 100644
--- a/apps/api/plane/app/views/module/issue.py
+++ b/apps/api/plane/app/views/module/issue.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import copy
import json
diff --git a/apps/api/plane/app/views/notification/base.py b/apps/api/plane/app/views/notification/base.py
index a11c12d167c..0b7dc27a8a8 100644
--- a/apps/api/plane/app/views/notification/base.py
+++ b/apps/api/plane/app/views/notification/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.db.models import Exists, OuterRef, Q, Case, When, BooleanField
from django.utils import timezone
diff --git a/apps/api/plane/app/views/page/base.py b/apps/api/plane/app/views/page/base.py
index 50daf440adc..ec391afc1aa 100644
--- a/apps/api/plane/app/views/page/base.py
+++ b/apps/api/plane/app/views/page/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
from datetime import datetime
@@ -46,7 +50,7 @@
# Local imports
from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
-from plane.bgtasks.page_version_task import page_version
+from plane.bgtasks.page_version_task import track_page_version
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets
from plane.app.permissions import ProjectPagePermission
@@ -128,7 +132,7 @@ def create(self, request, slug, project_id):
context={
"project_id": project_id,
"owned_by_id": request.user.id,
- "description": request.data.get("description", {}),
+ "description_json": request.data.get("description_json", {}),
"description_binary": request.data.get("description_binary", None),
"description_html": request.data.get("description_html", ""),
},
@@ -495,14 +499,12 @@ class PagesDescriptionViewSet(BaseViewSet):
permission_classes = [ProjectPagePermission]
def retrieve(self, request, slug, project_id, page_id):
- page = (
- Page.objects.get(
- Q(owned_by=self.request.user) | Q(access=0),
- pk=page_id,
- workspace__slug=slug,
- projects__id=project_id,
- project_pages__deleted_at__isnull=True,
- )
+ page = Page.objects.get(
+ Q(owned_by=self.request.user) | Q(access=0),
+ pk=page_id,
+ workspace__slug=slug,
+ projects__id=project_id,
+ project_pages__deleted_at__isnull=True,
)
binary_data = page.description_binary
@@ -517,14 +519,12 @@ def stream_data():
return response
def partial_update(self, request, slug, project_id, page_id):
- page = (
- Page.objects.get(
- Q(owned_by=self.request.user) | Q(access=0),
- pk=page_id,
- workspace__slug=slug,
- projects__id=project_id,
- project_pages__deleted_at__isnull=True,
- )
+ page = Page.objects.get(
+ Q(owned_by=self.request.user) | Q(access=0),
+ pk=page_id,
+ workspace__slug=slug,
+ projects__id=project_id,
+ project_pages__deleted_at__isnull=True,
)
if page.is_locked:
@@ -545,26 +545,28 @@ def partial_update(self, request, slug, project_id, page_id):
status=status.HTTP_400_BAD_REQUEST,
)
+ # Store the old description_html before saving (needed for both tasks)
+ old_description_html = page.description_html
+
# Serialize the existing instance
- existing_instance = json.dumps({"description_html": page.description_html}, cls=DjangoJSONEncoder)
+ existing_instance = json.dumps({"description_html": old_description_html}, cls=DjangoJSONEncoder)
# Use serializer for validation and update
serializer = PageBinaryUpdateSerializer(page, data=request.data, partial=True)
if serializer.is_valid():
+ serializer.save()
+
# Capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_description_html=request.data.get("description_html", ""),
- old_description_html=page.description_html,
+ old_description_html=old_description_html,
page_id=page_id,
)
- # Update the page using serializer
- updated_page = serializer.save()
-
# Run background tasks
- page_version.delay(
- page_id=updated_page.id,
+ track_page_version.delay(
+ page_id=page_id,
existing_instance=existing_instance,
user_id=request.user.id,
)
diff --git a/apps/api/plane/app/views/page/version.py b/apps/api/plane/app/views/page/version.py
index 1b285c96615..e102bf1d0b9 100644
--- a/apps/api/plane/app/views/page/version.py
+++ b/apps/api/plane/app/views/page/version.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third party imports
from rest_framework import status
from rest_framework.response import Response
diff --git a/apps/api/plane/app/views/project/base.py b/apps/api/plane/app/views/project/base.py
index 3dd1e3db42b..0a7378c076f 100644
--- a/apps/api/plane/app/views/project/base.py
+++ b/apps/api/plane/app/views/project/base.py
@@ -1,17 +1,18 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
-import boto3
# Django imports
-from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
-from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
+from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery, Count
from django.utils import timezone
# Third Party imports
from rest_framework import status
-from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
@@ -28,18 +29,17 @@
UserFavorite,
DeployBoard,
Intake,
- IssueUserProperty,
Project,
ProjectIdentifier,
ProjectMember,
ProjectNetwork,
+ ProjectUserProperty,
State,
DEFAULT_STATES,
Workspace,
WorkspaceMember,
)
-from plane.utils.cache import cache_response
-from plane.utils.exception_logger import log_exception
+from plane.db.models.intake import IntakeIssueStatus
from plane.utils.host import base_host
@@ -50,11 +50,10 @@ class ProjectViewSet(BaseViewSet):
use_read_replica = True
def get_queryset(self):
- sort_order = ProjectMember.objects.filter(
- member=self.request.user,
+ sort_order = ProjectUserProperty.objects.filter(
+ user=self.request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
- is_active=True,
).values("sort_order")
return self.filter_queryset(
super()
@@ -140,11 +139,10 @@ def list_detail(self, request, slug):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def list(self, request, slug):
- sort_order = ProjectMember.objects.filter(
- member=self.request.user,
+ sort_order = ProjectUserProperty.objects.filter(
+ user=self.request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
- is_active=True,
).values("sort_order")
projects = (
@@ -157,6 +155,15 @@ def list(self, request, slug):
is_active=True,
).values("role")
)
+ .annotate(
+ intake_count=Count(
+ "project_intakeissue",
+ filter=Q(
+ project_intakeissue__status=IntakeIssueStatus.PENDING.value,
+ project_intakeissue__deleted_at__isnull=True,
+ ),
+ )
+ )
.annotate(inbox_view=F("intake_view"))
.annotate(sort_order=Subquery(sort_order))
.distinct()
@@ -167,6 +174,7 @@ def list(self, request, slug):
"sort_order",
"logo_props",
"member_role",
+ "intake_count",
"archived_at",
"workspace",
"cycle_view",
@@ -255,8 +263,6 @@ def create(self, request, slug):
member=request.user,
role=ROLE.ADMIN.value,
)
- # Also create the issue property for the user
- _ = IssueUserProperty.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 +272,6 @@ def create(self, request, slug):
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(
[
diff --git a/apps/api/plane/app/views/project/invite.py b/apps/api/plane/app/views/project/invite.py
index cc5b3f4b577..19d8c36bcf7 100644
--- a/apps/api/plane/app/views/project/invite.py
+++ b/apps/api/plane/app/views/project/invite.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import jwt
from datetime import datetime
@@ -24,7 +28,7 @@
User,
WorkspaceMember,
Project,
- IssueUserProperty,
+ ProjectUserProperty,
)
from plane.db.models.project import ProjectNetwork
from plane.utils.host import base_host
@@ -160,9 +164,9 @@ def create(self, request, slug):
ignore_conflicts=True,
)
- IssueUserProperty.objects.bulk_create(
+ ProjectUserProperty.objects.bulk_create(
[
- IssueUserProperty(
+ ProjectUserProperty(
project_id=project_id,
user=request.user,
workspace=workspace,
@@ -220,7 +224,7 @@ def post(self, request, slug, project_id, pk):
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,
)
diff --git a/apps/api/plane/app/views/project/member.py b/apps/api/plane/app/views/project/member.py
index 3ab7061e15c..7dfe7090013 100644
--- a/apps/api/plane/app/views/project/member.py
+++ b/apps/api/plane/app/views/project/member.py
@@ -1,6 +1,11 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# 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 +18,7 @@
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 +94,23 @@ def create(self, request, slug, project_id):
# 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 +118,22 @@ def create(self, request, slug, project_id):
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,
@@ -222,21 +226,36 @@ def partial_update(self, request, slug, project_id, pk):
is_active=True,
)
- if workspace_role in [5] and int(request.data.get("role", project_member.role)) in [15, 20]:
- return Response(
- {"error": "You cannot add a user with role higher than the workspace role"},
- status=status.HTTP_400_BAD_REQUEST,
- )
+ if "role" in request.data:
+ # Only Admins can modify roles
+ if requested_project_member.role < ROLE.ADMIN.value and not is_workspace_admin:
+ return Response(
+ {"error": "You do not have permission to update roles"},
+ status=status.HTTP_403_FORBIDDEN,
+ )
- if (
- "role" in request.data
- and int(request.data.get("role", project_member.role)) > requested_project_member.role
- and not is_workspace_admin
- ):
- return Response(
- {"error": "You cannot update a role that is higher than your own role"},
- status=status.HTTP_400_BAD_REQUEST,
- )
+ # Cannot modify a member whose role is equal to or higher than your own
+ if project_member.role >= requested_project_member.role and not is_workspace_admin:
+ return Response(
+ {"error": "You cannot update the role of a member with a role equal to or higher than your own"},
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ new_role = int(request.data.get("role"))
+
+ # Cannot assign a role equal to or higher than your own
+ if new_role >= requested_project_member.role and not is_workspace_admin:
+ return Response(
+ {"error": "You cannot assign a role equal to or higher than your own"},
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ # Cannot assign a role higher than the target's workspace role
+ if workspace_role in [5] and new_role in [15, 20]:
+ return Response(
+ {"error": "You cannot add a user with role higher than the workspace role"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True)
diff --git a/apps/api/plane/app/views/search/base.py b/apps/api/plane/app/views/search/base.py
index f1e69265323..3bfbecaaff0 100644
--- a/apps/api/plane/app/views/search/base.py
+++ b/apps/api/plane/app/views/search/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import re
diff --git a/apps/api/plane/app/views/search/issue.py b/apps/api/plane/app/views/search/issue.py
index ac9d783a9ff..737fe641004 100644
--- a/apps/api/plane/app/views/search/issue.py
+++ b/apps/api/plane/app/views/search/issue.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.db.models import Q, QuerySet
diff --git a/apps/api/plane/app/views/state/base.py b/apps/api/plane/app/views/state/base.py
index ce1597a6855..55c232fdf64 100644
--- a/apps/api/plane/app/views/state/base.py
+++ b/apps/api/plane/app/views/state/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
from itertools import groupby
from collections import defaultdict
diff --git a/apps/api/plane/app/views/timezone/base.py b/apps/api/plane/app/views/timezone/base.py
index fc01211792b..9644ceee370 100644
--- a/apps/api/plane/app/views/timezone/base.py
+++ b/apps/api/plane/app/views/timezone/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import pytz
from datetime import datetime
diff --git a/apps/api/plane/app/views/user/base.py b/apps/api/plane/app/views/user/base.py
index 72d42010ced..914dffb3b02 100644
--- a/apps/api/plane/app/views/user/base.py
+++ b/apps/api/plane/app/views/user/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import uuid
import json
diff --git a/apps/api/plane/app/views/view/base.py b/apps/api/plane/app/views/view/base.py
index 98fe04c62fa..5ca7aac420f 100644
--- a/apps/api/plane/app/views/view/base.py
+++ b/apps/api/plane/app/views/view/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import copy
# Django imports
diff --git a/apps/api/plane/app/views/webhook/base.py b/apps/api/plane/app/views/webhook/base.py
index e857c3e08ff..c874f0a4225 100644
--- a/apps/api/plane/app/views/webhook/base.py
+++ b/apps/api/plane/app/views/webhook/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.db import IntegrityError
diff --git a/apps/api/plane/app/views/workspace/base.py b/apps/api/plane/app/views/workspace/base.py
index c27b7adbb26..be43eace2f6 100644
--- a/apps/api/plane/app/views/workspace/base.py
+++ b/apps/api/plane/app/views/workspace/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import csv
import io
@@ -42,7 +46,10 @@
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.workspace_seed_task import workspace_seed
+from plane.bgtasks.event_tracking_task import track_event
from plane.utils.url import contains_url
+from plane.utils.analytics_events import WORKSPACE_CREATED, WORKSPACE_DELETED
+from plane.utils.csv_utils import sanitize_csv_row
class WorkSpaceViewSet(BaseViewSet):
@@ -131,6 +138,20 @@ def create(self, request):
workspace_seed.delay(serializer.data["id"])
+ track_event.delay(
+ user_id=request.user.id,
+ event_name=WORKSPACE_CREATED,
+ slug=data["slug"],
+ event_properties={
+ "user_id": request.user.id,
+ "workspace_id": data["id"],
+ "workspace_slug": data["slug"],
+ "role": "owner",
+ "workspace_name": data["name"],
+ "created_at": data["created_at"],
+ },
+ )
+
return Response(data, status=status.HTTP_201_CREATED)
return Response(
[serializer.errors[error][0] for error in serializer.errors],
@@ -164,6 +185,19 @@ def destroy(self, request, *args, **kwargs):
# Get the workspace
workspace = self.get_object()
self.remove_last_workspace_ids_from_user_settings(workspace.id)
+ track_event.delay(
+ user_id=request.user.id,
+ event_name=WORKSPACE_DELETED,
+ slug=workspace.slug,
+ event_properties={
+ "user_id": request.user.id,
+ "workspace_id": workspace.id,
+ "workspace_slug": workspace.slug,
+ "role": "owner",
+ "workspace_name": workspace.name,
+ "deleted_at": str(timezone.now().isoformat()),
+ },
+ )
return super().destroy(request, *args, **kwargs)
@@ -338,7 +372,7 @@ def generate_csv_from_rows(self, rows):
"""Generate CSV buffer from rows."""
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
- [writer.writerow(row) for row in rows]
+ [writer.writerow(sanitize_csv_row(row)) for row in rows]
csv_buffer.seek(0)
return csv_buffer
diff --git a/apps/api/plane/app/views/workspace/cycle.py b/apps/api/plane/app/views/workspace/cycle.py
index 73deca0594e..deb86c5c4db 100644
--- a/apps/api/plane/app/views/workspace/cycle.py
+++ b/apps/api/plane/app/views/workspace/cycle.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.db.models import Q, Count
diff --git a/apps/api/plane/app/views/workspace/draft.py b/apps/api/plane/app/views/workspace/draft.py
index c89fe4a7304..aa228ded372 100644
--- a/apps/api/plane/app/views/workspace/draft.py
+++ b/apps/api/plane/app/views/workspace/draft.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
diff --git a/apps/api/plane/app/views/workspace/estimate.py b/apps/api/plane/app/views/workspace/estimate.py
index 8cba3d17000..7f5bb66f675 100644
--- a/apps/api/plane/app/views/workspace/estimate.py
+++ b/apps/api/plane/app/views/workspace/estimate.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third party modules
from rest_framework import status
from rest_framework.response import Response
diff --git a/apps/api/plane/app/views/workspace/favorite.py b/apps/api/plane/app/views/workspace/favorite.py
index 8a8bfed6c96..8217f0fb02e 100644
--- a/apps/api/plane/app/views/workspace/favorite.py
+++ b/apps/api/plane/app/views/workspace/favorite.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third party modules
from rest_framework import status
from rest_framework.response import Response
diff --git a/apps/api/plane/app/views/workspace/home.py b/apps/api/plane/app/views/workspace/home.py
index 731164eb1c7..ec35aaf4eca 100644
--- a/apps/api/plane/app/views/workspace/home.py
+++ b/apps/api/plane/app/views/workspace/home.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Module imports
from ..base import BaseAPIView
from plane.db.models.workspace import WorkspaceHomePreference
diff --git a/apps/api/plane/app/views/workspace/invite.py b/apps/api/plane/app/views/workspace/invite.py
index 48bcf7eba30..cf2ab795a73 100644
--- a/apps/api/plane/app/views/workspace/invite.py
+++ b/apps/api/plane/app/views/workspace/invite.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
from datetime import datetime
@@ -21,12 +25,12 @@
WorkSpaceMemberSerializer,
)
from plane.app.views.base import BaseAPIView
-from plane.bgtasks.event_tracking_task import workspace_invite_event
+from plane.bgtasks.event_tracking_task import track_event
from plane.bgtasks.workspace_invitation_task import workspace_invitation
from plane.db.models import User, Workspace, WorkspaceMember, WorkspaceMemberInvite
from plane.utils.cache import invalidate_cache, invalidate_cache_directly
from plane.utils.host import base_host
-from plane.utils.ip_address import get_client_ip
+from plane.utils.analytics_events import USER_JOINED_WORKSPACE, USER_INVITED_TO_WORKSPACE
from .. import BaseViewSet
@@ -121,6 +125,19 @@ def create(self, request, slug):
current_site,
request.user.email,
)
+ track_event.delay(
+ user_id=request.user.id,
+ event_name=USER_INVITED_TO_WORKSPACE,
+ slug=slug,
+ event_properties={
+ "user_id": request.user.id,
+ "workspace_id": workspace.id,
+ "workspace_slug": workspace.slug,
+ "invitee_role": invitation.role,
+ "invited_at": str(timezone.now()),
+ "invitee_email": invitation.email,
+ },
+ )
return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK)
@@ -146,10 +163,10 @@ class WorkspaceJoinEndpoint(BaseAPIView):
def post(self, request, slug, pk):
workspace_invite = WorkspaceMemberInvite.objects.get(pk=pk, workspace__slug=slug)
- email = request.data.get("email", "")
+ token = request.data.get("token", "")
- # Check the email
- if email == "" or workspace_invite.email != email:
+ # Validate the token to verify the user received the invitation email
+ if not token or workspace_invite.token != token:
return Response(
{"error": "You do not have permission to join the workspace"},
status=status.HTTP_403_FORBIDDEN,
@@ -163,7 +180,7 @@ def post(self, request, slug, pk):
if workspace_invite.accepted:
# Check if the user created account after invitation
- user = User.objects.filter(email=email).first()
+ user = User.objects.filter(email=workspace_invite.email).first()
# If the user is present then create the workspace member
if user is not None:
@@ -186,20 +203,22 @@ def post(self, request, slug, pk):
# Set the user last_workspace_id to the accepted workspace
user.last_workspace_id = workspace_invite.workspace.id
user.save()
+ track_event.delay(
+ user_id=user.id,
+ event_name=USER_JOINED_WORKSPACE,
+ slug=slug,
+ event_properties={
+ "user_id": user.id,
+ "workspace_id": workspace_invite.workspace.id,
+ "workspace_slug": workspace_invite.workspace.slug,
+ "role": workspace_invite.role,
+ "joined_at": str(timezone.now()),
+ },
+ )
# Delete the invitation
workspace_invite.delete()
- # Send event
- workspace_invite_event.delay(
- user=user.id if user is not None else None,
- email=email,
- user_agent=request.META.get("HTTP_USER_AGENT"),
- ip=get_client_ip(request=request),
- event_name="MEMBER_ACCEPTED",
- accepted_from="EMAIL",
- )
-
return Response(
{"message": "Workspace Invitation Accepted"},
status=status.HTTP_200_OK,
@@ -252,6 +271,20 @@ def create(self, request):
is_active=True, role=invitation.role
)
+ # Track event
+ track_event.delay(
+ user_id=request.user.id,
+ event_name=USER_JOINED_WORKSPACE,
+ slug=invitation.workspace.slug,
+ event_properties={
+ "user_id": request.user.id,
+ "workspace_id": invitation.workspace.id,
+ "workspace_slug": invitation.workspace.slug,
+ "role": invitation.role,
+ "joined_at": str(timezone.now()),
+ },
+ )
+
# Bulk create the user for all the workspaces
WorkspaceMember.objects.bulk_create(
[
diff --git a/apps/api/plane/app/views/workspace/label.py b/apps/api/plane/app/views/workspace/label.py
index 11ca6b9139b..926a504a34b 100644
--- a/apps/api/plane/app/views/workspace/label.py
+++ b/apps/api/plane/app/views/workspace/label.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third party modules
from rest_framework import status
from rest_framework.response import Response
diff --git a/apps/api/plane/app/views/workspace/member.py b/apps/api/plane/app/views/workspace/member.py
index 3394cb253f0..67c7637a8c2 100644
--- a/apps/api/plane/app/views/workspace/member.py
+++ b/apps/api/plane/app/views/workspace/member.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.db.models import Count, Q, OuterRef, Subquery, IntegerField
from django.utils import timezone
diff --git a/apps/api/plane/app/views/workspace/module.py b/apps/api/plane/app/views/workspace/module.py
index e61fc70e732..b048481402a 100644
--- a/apps/api/plane/app/views/workspace/module.py
+++ b/apps/api/plane/app/views/workspace/module.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.db.models import Prefetch, Q, Count
@@ -42,7 +46,7 @@ def get(self, request, slug):
)
.annotate(
completed_issues=Count(
- "issue_module__issue__state__group",
+ "issue_module",
filter=Q(
issue_module__issue__state__group="completed",
issue_module__issue__archived_at__isnull=True,
@@ -54,7 +58,7 @@ def get(self, request, slug):
)
.annotate(
cancelled_issues=Count(
- "issue_module__issue__state__group",
+ "issue_module",
filter=Q(
issue_module__issue__state__group="cancelled",
issue_module__issue__archived_at__isnull=True,
@@ -66,7 +70,7 @@ def get(self, request, slug):
)
.annotate(
started_issues=Count(
- "issue_module__issue__state__group",
+ "issue_module",
filter=Q(
issue_module__issue__state__group="started",
issue_module__issue__archived_at__isnull=True,
@@ -78,7 +82,7 @@ def get(self, request, slug):
)
.annotate(
unstarted_issues=Count(
- "issue_module__issue__state__group",
+ "issue_module",
filter=Q(
issue_module__issue__state__group="unstarted",
issue_module__issue__archived_at__isnull=True,
@@ -90,7 +94,7 @@ def get(self, request, slug):
)
.annotate(
backlog_issues=Count(
- "issue_module__issue__state__group",
+ "issue_module",
filter=Q(
issue_module__issue__state__group="backlog",
issue_module__issue__archived_at__isnull=True,
diff --git a/apps/api/plane/app/views/workspace/quick_link.py b/apps/api/plane/app/views/workspace/quick_link.py
index 82c104573d8..ba971c54f9e 100644
--- a/apps/api/plane/app/views/workspace/quick_link.py
+++ b/apps/api/plane/app/views/workspace/quick_link.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third party imports
from rest_framework import status
from rest_framework.response import Response
diff --git a/apps/api/plane/app/views/workspace/recent_visit.py b/apps/api/plane/app/views/workspace/recent_visit.py
index 0d9c1ef9bb4..a9394766a20 100644
--- a/apps/api/plane/app/views/workspace/recent_visit.py
+++ b/apps/api/plane/app/views/workspace/recent_visit.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third party imports
from rest_framework import status
from rest_framework.response import Response
diff --git a/apps/api/plane/app/views/workspace/state.py b/apps/api/plane/app/views/workspace/state.py
index 3bfc8d22def..a8c5b368d99 100644
--- a/apps/api/plane/app/views/workspace/state.py
+++ b/apps/api/plane/app/views/workspace/state.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third party modules
from rest_framework import status
from rest_framework.response import Response
diff --git a/apps/api/plane/app/views/workspace/sticky.py b/apps/api/plane/app/views/workspace/sticky.py
index 8ab6c5c9824..9cf1532257b 100644
--- a/apps/api/plane/app/views/workspace/sticky.py
+++ b/apps/api/plane/app/views/workspace/sticky.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third party imports
from rest_framework.response import Response
from rest_framework import status
diff --git a/apps/api/plane/app/views/workspace/user.py b/apps/api/plane/app/views/workspace/user.py
index b45c6e410b9..b60ae5e15eb 100644
--- a/apps/api/plane/app/views/workspace/user.py
+++ b/apps/api/plane/app/views/workspace/user.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import copy
from datetime import date
diff --git a/apps/api/plane/app/views/workspace/user_preference.py b/apps/api/plane/app/views/workspace/user_preference.py
index 253f715b36c..83e380b9ec1 100644
--- a/apps/api/plane/app/views/workspace/user_preference.py
+++ b/apps/api/plane/app/views/workspace/user_preference.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Module imports
from ..base import BaseAPIView
from plane.db.models.workspace import WorkspaceUserPreference
diff --git a/apps/api/plane/asgi.py b/apps/api/plane/asgi.py
index 2dd703ffef1..9d3bd6b07a0 100644
--- a/apps/api/plane/asgi.py
+++ b/apps/api/plane/asgi.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import os
from channels.routing import ProtocolTypeRouter
diff --git a/apps/api/plane/authentication/__init__.py b/apps/api/plane/authentication/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/authentication/__init__.py
+++ b/apps/api/plane/authentication/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/authentication/adapter/__init__.py b/apps/api/plane/authentication/adapter/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/authentication/adapter/__init__.py
+++ b/apps/api/plane/authentication/adapter/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/authentication/adapter/base.py b/apps/api/plane/authentication/adapter/base.py
index b80555fe16e..b80dfa5002f 100644
--- a/apps/api/plane/authentication/adapter/base.py
+++ b/apps/api/plane/authentication/adapter/base.py
@@ -1,26 +1,35 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
+import logging
import os
import uuid
-import requests
from io import BytesIO
+import requests
+from django.conf import settings
+from django.core.exceptions import ValidationError
+from django.core.validators import validate_email
+
# Django imports
from django.utils import timezone
-from django.core.validators import validate_email
-from django.core.exceptions import ValidationError
-from django.conf import settings
# Third party imports
from zxcvbn import zxcvbn
+from plane.bgtasks.user_activation_email_task import user_activation_email
+
# Module imports
-from plane.db.models import Profile, User, WorkspaceMemberInvite, FileAsset
+from plane.db.models import FileAsset, Profile, User, WorkspaceMemberInvite
from plane.license.utils.instance_value import get_configuration_value
-from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES
-from plane.bgtasks.user_activation_email_task import user_activation_email
+from plane.settings.storage import S3Storage
+from plane.utils.exception_logger import log_exception
from plane.utils.host import base_host
from plane.utils.ip_address import get_client_ip
-from plane.utils.exception_logger import log_exception
+
+from .error import AUTHENTICATION_ERROR_CODES, AuthenticationException
class Adapter:
@@ -32,6 +41,7 @@ def __init__(self, request, provider, callback=None):
self.callback = callback
self.token_data = None
self.user_data = None
+ self.logger = logging.getLogger("plane.authentication")
def get_user_token(self, data, headers=None):
raise NotImplementedError
@@ -54,6 +64,7 @@ def authenticate(self):
def sanitize_email(self, email):
# Check if email is present
if not email:
+ self.logger.error("Email is not present")
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
error_message="INVALID_EMAIL",
@@ -67,6 +78,7 @@ def sanitize_email(self, email):
try:
validate_email(email)
except ValidationError:
+ self.logger.warning(f"Email is not valid: {email}")
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
error_message="INVALID_EMAIL",
@@ -79,9 +91,10 @@ def validate_password(self, email):
"""Validate password strength"""
results = zxcvbn(self.code)
if results["score"] < 3:
+ self.logger.warning("Password is not strong enough")
raise AuthenticationException(
- error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
- error_message="INVALID_PASSWORD",
+ error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
+ error_message="PASSWORD_TOO_WEAK",
payload={"email": email},
)
return
@@ -96,6 +109,7 @@ def __check_signup(self, email):
# Check if sign up is disabled and invite is present or not
if ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter(email=email).exists():
+ self.logger.warning("Sign up is disabled and invite is not present")
# Raise exception
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
@@ -108,6 +122,20 @@ def __check_signup(self, email):
def get_avatar_download_headers(self):
return {}
+ def check_sync_enabled(self):
+ """Check if sync is enabled for the provider"""
+ provider_config_map = {
+ "google": "ENABLE_GOOGLE_SYNC",
+ "github": "ENABLE_GITHUB_SYNC",
+ "gitlab": "ENABLE_GITLAB_SYNC",
+ "gitea": "ENABLE_GITEA_SYNC",
+ }
+ config_key = provider_config_map.get(self.provider)
+ if config_key:
+ (enabled,) = get_configuration_value([{"key": config_key, "default": os.environ.get(config_key, "0")}])
+ return enabled == "1"
+ return False
+
def download_and_upload_avatar(self, avatar_url, user):
"""
Downloads avatar from OAuth provider and uploads to our storage.
@@ -156,9 +184,6 @@ def download_and_upload_avatar(self, avatar_url, user):
# Generate unique filename
filename = f"{uuid.uuid4().hex}-user-avatar.{extension}"
- # Upload to S3/MinIO storage
- from plane.settings.storage import S3Storage
-
storage = S3Storage(request=self.request)
# Create file-like object
@@ -208,6 +233,59 @@ def save_user_data(self, user):
user.save()
return user
+ def delete_old_avatar(self, user):
+ """Delete the old avatar if it exists"""
+ try:
+ if user.avatar_asset:
+ asset = FileAsset.objects.get(pk=user.avatar_asset_id)
+ storage = S3Storage(request=self.request)
+ storage.delete_files(object_names=[asset.asset.name])
+
+ # Delete the user avatar
+ asset.delete()
+ user.avatar_asset = None
+ user.avatar = ""
+ user.save()
+ return
+ except FileAsset.DoesNotExist:
+ pass
+ except Exception as e:
+ log_exception(e)
+ return
+
+ def sync_user_data(self, user):
+ # Update user details
+ first_name = self.user_data.get("user", {}).get("first_name", "")
+ last_name = self.user_data.get("user", {}).get("last_name", "")
+ user.first_name = first_name if first_name else ""
+ user.last_name = last_name if last_name else ""
+
+ # Get email
+ email = self.user_data.get("email")
+
+ # Get display name
+ display_name = self.user_data.get("user", {}).get("display_name")
+ # If display name is not provided, generate a random display name
+ if not display_name:
+ display_name = User.get_display_name(email)
+
+ # Set display name
+ user.display_name = display_name
+
+ # Download and upload avatar only if the avatar is different from the one in the storage
+ avatar = self.user_data.get("user", {}).get("avatar", "")
+ # Delete the old avatar if it exists
+ self.delete_old_avatar(user=user)
+ avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user)
+ if avatar_asset:
+ user.avatar_asset = avatar_asset
+ # If avatar upload fails, set the avatar to the original URL
+ else:
+ user.avatar = avatar
+
+ user.save()
+ return user
+
def complete_login_or_signup(self):
# Get email
email = self.user_data.get("email")
@@ -255,6 +333,7 @@ def complete_login_or_signup(self):
avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user)
if avatar_asset:
user.avatar_asset = avatar_asset
+ user.avatar = avatar
# If avatar upload fails, set the avatar to the original URL
else:
user.avatar = avatar
@@ -262,6 +341,10 @@ def complete_login_or_signup(self):
# Create profile
Profile.objects.create(user=user)
+ # Check if IDP sync is enabled and user is not signing up
+ if self.check_sync_enabled() and not is_signup:
+ user = self.sync_user_data(user=user)
+
# Save user data
user = self.save_user_data(user=user)
diff --git a/apps/api/plane/authentication/adapter/credential.py b/apps/api/plane/authentication/adapter/credential.py
index 0327289ca26..eee6ea97f6b 100644
--- a/apps/api/plane/authentication/adapter/credential.py
+++ b/apps/api/plane/authentication/adapter/credential.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from plane.authentication.adapter.base import Adapter
diff --git a/apps/api/plane/authentication/adapter/error.py b/apps/api/plane/authentication/adapter/error.py
index 25a7cf56717..f91565df2e8 100644
--- a/apps/api/plane/authentication/adapter/error.py
+++ b/apps/api/plane/authentication/adapter/error.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
AUTHENTICATION_ERROR_CODES = {
# Global
"INSTANCE_NOT_CONFIGURED": 5000,
@@ -9,6 +13,7 @@
"USER_ACCOUNT_DEACTIVATED": 5019,
# Password strength
"INVALID_PASSWORD": 5020,
+ "PASSWORD_TOO_WEAK": 5021,
"SMTP_NOT_CONFIGURED": 5025,
# Sign Up
"USER_ALREADY_EXIST": 5030,
diff --git a/apps/api/plane/authentication/adapter/exception.py b/apps/api/plane/authentication/adapter/exception.py
index e906c5a50bd..c8d28762a90 100644
--- a/apps/api/plane/authentication/adapter/exception.py
+++ b/apps/api/plane/authentication/adapter/exception.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third party imports
from rest_framework.views import exception_handler
from rest_framework.exceptions import NotAuthenticated
diff --git a/apps/api/plane/authentication/adapter/oauth.py b/apps/api/plane/authentication/adapter/oauth.py
index d8e423d0e7e..0bef76b2487 100644
--- a/apps/api/plane/authentication/adapter/oauth.py
+++ b/apps/api/plane/authentication/adapter/oauth.py
@@ -1,20 +1,25 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import requests
+from django.db import DatabaseError, IntegrityError
# Django imports
from django.utils import timezone
-from django.db import DatabaseError, IntegrityError
-# Module imports
-from plane.db.models import Account
-
-from .base import Adapter
from plane.authentication.adapter.error import (
- AuthenticationException,
AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
)
+
+# Module imports
+from plane.db.models import Account
from plane.utils.exception_logger import log_exception
+from .base import Adapter
+
class OauthAdapter(Adapter):
def __init__(
@@ -74,6 +79,7 @@ def get_user_token(self, data, headers=None):
response.raise_for_status()
return response.json()
except requests.RequestException:
+ self.logger.warning("Error getting user token")
code = self.authentication_error_code()
raise AuthenticationException(error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code))
@@ -84,6 +90,12 @@ def get_user_response(self):
response.raise_for_status()
return response.json()
except requests.RequestException:
+ self.logger.warning(
+ "Error getting user response",
+ extra={
+ "headers": headers,
+ },
+ )
code = self.authentication_error_code()
raise AuthenticationException(error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code))
diff --git a/apps/api/plane/authentication/apps.py b/apps/api/plane/authentication/apps.py
index cf5cdca1c96..5a612eaa98c 100644
--- a/apps/api/plane/authentication/apps.py
+++ b/apps/api/plane/authentication/apps.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.apps import AppConfig
diff --git a/apps/api/plane/authentication/middleware/__init__.py b/apps/api/plane/authentication/middleware/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/authentication/middleware/__init__.py
+++ b/apps/api/plane/authentication/middleware/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/authentication/middleware/session.py b/apps/api/plane/authentication/middleware/session.py
index c367a15d36f..817f898fd43 100644
--- a/apps/api/plane/authentication/middleware/session.py
+++ b/apps/api/plane/authentication/middleware/session.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import time
from importlib import import_module
diff --git a/apps/api/plane/authentication/provider/__init__.py b/apps/api/plane/authentication/provider/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/authentication/provider/__init__.py
+++ b/apps/api/plane/authentication/provider/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/authentication/provider/credentials/__init__.py b/apps/api/plane/authentication/provider/credentials/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/authentication/provider/credentials/__init__.py
+++ b/apps/api/plane/authentication/provider/credentials/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/authentication/provider/credentials/email.py b/apps/api/plane/authentication/provider/credentials/email.py
index c3d19a80e7c..e2c424588a2 100644
--- a/apps/api/plane/authentication/provider/credentials/email.py
+++ b/apps/api/plane/authentication/provider/credentials/email.py
@@ -1,13 +1,17 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
# Module imports
from plane.authentication.adapter.credential import CredentialAdapter
-from plane.db.models import User
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
+from plane.db.models import User
from plane.license.utils.instance_value import get_configuration_value
@@ -20,14 +24,12 @@ def __init__(self, request, key=None, code=None, is_signup=False, callback=None)
self.code = code
self.is_signup = is_signup
- (ENABLE_EMAIL_PASSWORD,) = get_configuration_value(
- [
- {
- "key": "ENABLE_EMAIL_PASSWORD",
- "default": os.environ.get("ENABLE_EMAIL_PASSWORD"),
- }
- ]
- )
+ (ENABLE_EMAIL_PASSWORD,) = get_configuration_value([
+ {
+ "key": "ENABLE_EMAIL_PASSWORD",
+ "default": os.environ.get("ENABLE_EMAIL_PASSWORD"),
+ }
+ ])
if ENABLE_EMAIL_PASSWORD == "0":
raise AuthenticationException(
@@ -39,29 +41,29 @@ def set_user_data(self):
if self.is_signup:
# Check if the user already exists
if User.objects.filter(email=self.key).exists():
+ self.logger.warning("User already exists")
raise AuthenticationException(
error_message="USER_ALREADY_EXIST",
error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
)
- super().set_user_data(
- {
- "email": self.key,
- "user": {
- "avatar": "",
- "first_name": "",
- "last_name": "",
- "provider_id": "",
- "is_password_autoset": False,
- },
- }
- )
+ super().set_user_data({
+ "email": self.key,
+ "user": {
+ "avatar": "",
+ "first_name": "",
+ "last_name": "",
+ "provider_id": "",
+ "is_password_autoset": False,
+ },
+ })
return
else:
user = User.objects.filter(email=self.key).first()
# User does not exists
if not user:
+ self.logger.warning("User does not exist")
raise AuthenticationException(
error_message="USER_DOES_NOT_EXIST",
error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
@@ -70,6 +72,7 @@ def set_user_data(self):
# Check user password
if not user.check_password(self.code):
+ self.logger.warning("Authentication failed - invalid credentials")
raise AuthenticationException(
error_message=(
"AUTHENTICATION_FAILED_SIGN_UP" if self.is_signup else "AUTHENTICATION_FAILED_SIGN_IN"
@@ -80,16 +83,14 @@ def set_user_data(self):
payload={"email": self.key},
)
- super().set_user_data(
- {
- "email": self.key,
- "user": {
- "avatar": "",
- "first_name": "",
- "last_name": "",
- "provider_id": "",
- "is_password_autoset": False,
- },
- }
- )
+ super().set_user_data({
+ "email": self.key,
+ "user": {
+ "avatar": "",
+ "first_name": "",
+ "last_name": "",
+ "provider_id": "",
+ "is_password_autoset": False,
+ },
+ })
return
diff --git a/apps/api/plane/authentication/provider/credentials/magic_code.py b/apps/api/plane/authentication/provider/credentials/magic_code.py
index e7c5cfff956..a6c9883d6b2 100644
--- a/apps/api/plane/authentication/provider/credentials/magic_code.py
+++ b/apps/api/plane/authentication/provider/credentials/magic_code.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
import os
diff --git a/apps/api/plane/authentication/provider/oauth/__init__.py b/apps/api/plane/authentication/provider/oauth/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/authentication/provider/oauth/__init__.py
+++ b/apps/api/plane/authentication/provider/oauth/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/authentication/provider/oauth/gitea.py b/apps/api/plane/authentication/provider/oauth/gitea.py
index ba7d3d16ba3..8c0c3a5db51 100644
--- a/apps/api/plane/authentication/provider/oauth/gitea.py
+++ b/apps/api/plane/authentication/provider/oauth/gitea.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import os
from datetime import datetime, timedelta
from urllib.parse import urlencode, urlparse
@@ -101,9 +105,7 @@ def set_token_data(self):
else None
),
"refresh_token_expired_at": (
- datetime.fromtimestamp(
- token_response.get("refresh_token_expired_at"), tz=pytz.utc
- )
+ datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc)
if token_response.get("refresh_token_expired_at")
else None
),
@@ -168,4 +170,4 @@ def set_user_data(self):
"is_password_autoset": True,
},
}
- )
\ No newline at end of file
+ )
diff --git a/apps/api/plane/authentication/provider/oauth/github.py b/apps/api/plane/authentication/provider/oauth/github.py
index 54c48018eff..363cd722e5e 100644
--- a/apps/api/plane/authentication/provider/oauth/github.py
+++ b/apps/api/plane/authentication/provider/oauth/github.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
from datetime import datetime
@@ -6,14 +10,15 @@
import pytz
import requests
-# Module imports
-from plane.authentication.adapter.oauth import OauthAdapter
-from plane.license.utils.instance_value import get_configuration_value
from plane.authentication.adapter.error import (
- AuthenticationException,
AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
)
+# Module imports
+from plane.authentication.adapter.oauth import OauthAdapter
+from plane.license.utils.instance_value import get_configuration_value
+
class GitHubOAuthProvider(OauthAdapter):
token_url = "https://github.com/login/oauth/access_token"
@@ -26,22 +31,20 @@ class GitHubOAuthProvider(OauthAdapter):
organization_scope = "read:org"
def __init__(self, request, code=None, state=None, callback=None):
- GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value(
- [
- {
- "key": "GITHUB_CLIENT_ID",
- "default": os.environ.get("GITHUB_CLIENT_ID"),
- },
- {
- "key": "GITHUB_CLIENT_SECRET",
- "default": os.environ.get("GITHUB_CLIENT_SECRET"),
- },
- {
- "key": "GITHUB_ORGANIZATION_ID",
- "default": os.environ.get("GITHUB_ORGANIZATION_ID"),
- },
- ]
- )
+ GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value([
+ {
+ "key": "GITHUB_CLIENT_ID",
+ "default": os.environ.get("GITHUB_CLIENT_ID"),
+ },
+ {
+ "key": "GITHUB_CLIENT_SECRET",
+ "default": os.environ.get("GITHUB_CLIENT_SECRET"),
+ },
+ {
+ "key": "GITHUB_ORGANIZATION_ID",
+ "default": os.environ.get("GITHUB_ORGANIZATION_ID"),
+ },
+ ])
if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET):
raise AuthenticationException(
@@ -86,32 +89,46 @@ def set_token_data(self):
"redirect_uri": self.redirect_uri,
}
token_response = self.get_user_token(data=data, headers={"Accept": "application/json"})
- super().set_token_data(
- {
- "access_token": token_response.get("access_token"),
- "refresh_token": token_response.get("refresh_token", None),
- "access_token_expired_at": (
- datetime.fromtimestamp(token_response.get("expires_in"), tz=pytz.utc)
- if token_response.get("expires_in")
- else None
- ),
- "refresh_token_expired_at": (
- datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc)
- if token_response.get("refresh_token_expired_at")
- else None
- ),
- "id_token": token_response.get("id_token", ""),
- }
- )
+ super().set_token_data({
+ "access_token": token_response.get("access_token"),
+ "refresh_token": token_response.get("refresh_token", None),
+ "access_token_expired_at": (
+ datetime.fromtimestamp(token_response.get("expires_in"), tz=pytz.utc)
+ if token_response.get("expires_in")
+ else None
+ ),
+ "refresh_token_expired_at": (
+ datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc)
+ if token_response.get("refresh_token_expired_at")
+ else None
+ ),
+ "id_token": token_response.get("id_token", ""),
+ })
def __get_email(self, headers):
try:
# Github does not provide email in user response
emails_url = "https://api.github.com/user/emails"
emails_response = requests.get(emails_url, headers=headers).json()
+ # Ensure the response is a list before iterating
+ if not isinstance(emails_response, list):
+ self.logger.error("Unexpected response format from GitHub emails API")
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
+ error_message="GITHUB_OAUTH_PROVIDER_ERROR",
+ )
email = next((email["email"] for email in emails_response if email["primary"]), None)
+ if not email:
+ self.logger.error("No primary email found for user")
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
+ error_message="GITHUB_OAUTH_PROVIDER_ERROR",
+ )
return email
except requests.RequestException:
+ self.logger.warning(
+ "Error getting email from GitHub",
+ )
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
@@ -134,22 +151,33 @@ def set_user_data(self):
if self.organization_id:
if not self.is_user_in_organization(user_info_response.get("login")):
+ self.logger.warning(
+ "User is not in organization",
+ extra={
+ "organization_id": self.organization_id,
+ "user_login": user_info_response.get("login"),
+ },
+ )
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_USER_NOT_IN_ORG"],
error_message="GITHUB_USER_NOT_IN_ORG",
)
email = self.__get_email(headers=headers)
- super().set_user_data(
- {
+ self.logger.debug(
+ "Email found",
+ extra={
"email": email,
- "user": {
- "provider_id": user_info_response.get("id"),
- "email": email,
- "avatar": user_info_response.get("avatar_url"),
- "first_name": user_info_response.get("name"),
- "last_name": user_info_response.get("family_name"),
- "is_password_autoset": True,
- },
- }
+ },
)
+ super().set_user_data({
+ "email": email,
+ "user": {
+ "provider_id": user_info_response.get("id"),
+ "email": email,
+ "avatar": user_info_response.get("avatar_url"),
+ "first_name": user_info_response.get("name"),
+ "last_name": user_info_response.get("family_name"),
+ "is_password_autoset": True,
+ },
+ })
diff --git a/apps/api/plane/authentication/provider/oauth/gitlab.py b/apps/api/plane/authentication/provider/oauth/gitlab.py
index de4a3515efb..088987c2379 100644
--- a/apps/api/plane/authentication/provider/oauth/gitlab.py
+++ b/apps/api/plane/authentication/provider/oauth/gitlab.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
from datetime import datetime
diff --git a/apps/api/plane/authentication/provider/oauth/google.py b/apps/api/plane/authentication/provider/oauth/google.py
index 41293782f82..b02eda87de3 100644
--- a/apps/api/plane/authentication/provider/oauth/google.py
+++ b/apps/api/plane/authentication/provider/oauth/google.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
from datetime import datetime
diff --git a/apps/api/plane/authentication/rate_limit.py b/apps/api/plane/authentication/rate_limit.py
index d245d50b37b..f939ef25cd4 100644
--- a/apps/api/plane/authentication/rate_limit.py
+++ b/apps/api/plane/authentication/rate_limit.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third party imports
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
from rest_framework import status
diff --git a/apps/api/plane/authentication/session.py b/apps/api/plane/authentication/session.py
index 862a63c1300..fe2aa0c35c0 100644
--- a/apps/api/plane/authentication/session.py
+++ b/apps/api/plane/authentication/session.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from rest_framework.authentication import SessionAuthentication
diff --git a/apps/api/plane/authentication/urls.py b/apps/api/plane/authentication/urls.py
index 64b8e654c9f..4bec07db00b 100644
--- a/apps/api/plane/authentication/urls.py
+++ b/apps/api/plane/authentication/urls.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
from .views import (
diff --git a/apps/api/plane/authentication/utils/host.py b/apps/api/plane/authentication/utils/host.py
index 415791a879c..d79d54e8a80 100644
--- a/apps/api/plane/authentication/utils/host.py
+++ b/apps/api/plane/authentication/utils/host.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.conf import settings
from django.http import HttpRequest
diff --git a/apps/api/plane/authentication/utils/login.py b/apps/api/plane/authentication/utils/login.py
index fe6fdad931a..d573335511f 100644
--- a/apps/api/plane/authentication/utils/login.py
+++ b/apps/api/plane/authentication/utils/login.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.contrib.auth import login
from django.conf import settings
diff --git a/apps/api/plane/authentication/utils/redirection_path.py b/apps/api/plane/authentication/utils/redirection_path.py
index 82139b82139..59d4b7d50a8 100644
--- a/apps/api/plane/authentication/utils/redirection_path.py
+++ b/apps/api/plane/authentication/utils/redirection_path.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from plane.db.models import Profile, Workspace, WorkspaceMemberInvite
diff --git a/apps/api/plane/authentication/utils/user_auth_workflow.py b/apps/api/plane/authentication/utils/user_auth_workflow.py
index 13de4c28744..4641f332c5a 100644
--- a/apps/api/plane/authentication/utils/user_auth_workflow.py
+++ b/apps/api/plane/authentication/utils/user_auth_workflow.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from .workspace_project_join import process_workspace_project_invitations
diff --git a/apps/api/plane/authentication/utils/workspace_project_join.py b/apps/api/plane/authentication/utils/workspace_project_join.py
index bd5ad8501b2..9222791a845 100644
--- a/apps/api/plane/authentication/utils/workspace_project_join.py
+++ b/apps/api/plane/authentication/utils/workspace_project_join.py
@@ -1,3 +1,11 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
+# Django imports
+from django.utils import timezone
+
+# Module imports
from plane.db.models import (
ProjectMember,
ProjectMemberInvite,
@@ -5,6 +13,8 @@
WorkspaceMemberInvite,
)
from plane.utils.cache import invalidate_cache_directly
+from plane.bgtasks.event_tracking_task import track_event
+from plane.utils.analytics_events import USER_JOINED_WORKSPACE
def process_workspace_project_invitations(user):
@@ -25,15 +35,25 @@ def process_workspace_project_invitations(user):
ignore_conflicts=True,
)
- [
+ for workspace_member_invite in workspace_member_invites:
invalidate_cache_directly(
path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/",
url_params=False,
user=False,
multiple=True,
)
- for workspace_member_invite in workspace_member_invites
- ]
+ track_event.delay(
+ user_id=user.id,
+ event_name=USER_JOINED_WORKSPACE,
+ slug=workspace_member_invite.workspace.slug,
+ event_properties={
+ "user_id": user.id,
+ "workspace_id": workspace_member_invite.workspace.id,
+ "workspace_slug": workspace_member_invite.workspace.slug,
+ "role": workspace_member_invite.role,
+ "joined_at": str(timezone.now().isoformat()),
+ },
+ )
# Check if user has any project invites
project_member_invites = ProjectMemberInvite.objects.filter(email=user.email, accepted=True)
diff --git a/apps/api/plane/authentication/views/__init__.py b/apps/api/plane/authentication/views/__init__.py
index 2595d2e7566..a9c816ae9ea 100644
--- a/apps/api/plane/authentication/views/__init__.py
+++ b/apps/api/plane/authentication/views/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from .common import ChangePasswordEndpoint, CSRFTokenEndpoint, SetUserPasswordEndpoint
from .app.check import EmailCheckEndpoint
diff --git a/apps/api/plane/authentication/views/app/check.py b/apps/api/plane/authentication/views/app/check.py
index 10457b45a04..97ab24def1b 100644
--- a/apps/api/plane/authentication/views/app/check.py
+++ b/apps/api/plane/authentication/views/app/check.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
diff --git a/apps/api/plane/authentication/views/app/email.py b/apps/api/plane/authentication/views/app/email.py
index 864ff102bc8..3d1954875c4 100644
--- a/apps/api/plane/authentication/views/app/email.py
+++ b/apps/api/plane/authentication/views/app/email.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
diff --git a/apps/api/plane/authentication/views/app/gitea.py b/apps/api/plane/authentication/views/app/gitea.py
index fd12f8b3363..67d25e1ab37 100644
--- a/apps/api/plane/authentication/views/app/gitea.py
+++ b/apps/api/plane/authentication/views/app/gitea.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import uuid
from urllib.parse import urlencode, urljoin
@@ -37,9 +41,7 @@ def get(self, request):
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
- url = urljoin(
- base_host(request=request, is_app=True), "?" + urlencode(params)
- )
+ url = urljoin(base_host(request=request, is_app=True), "?" + urlencode(params))
return HttpResponseRedirect(url)
try:
state = uuid.uuid4().hex
@@ -51,9 +53,7 @@ def get(self, request):
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
- url = urljoin(
- base_host(request=request, is_app=True), "?" + urlencode(params)
- )
+ url = urljoin(base_host(request=request, is_app=True), "?" + urlencode(params))
return HttpResponseRedirect(url)
@@ -87,9 +87,7 @@ def get(self, request):
return HttpResponseRedirect(url)
try:
- provider = GiteaOAuthProvider(
- request=request, code=code, callback=post_user_auth_workflow
- )
+ provider = GiteaOAuthProvider(request=request, code=code, callback=post_user_auth_workflow)
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
diff --git a/apps/api/plane/authentication/views/app/github.py b/apps/api/plane/authentication/views/app/github.py
index 4720fc7daa8..82d5f4a0538 100644
--- a/apps/api/plane/authentication/views/app/github.py
+++ b/apps/api/plane/authentication/views/app/github.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import uuid
diff --git a/apps/api/plane/authentication/views/app/gitlab.py b/apps/api/plane/authentication/views/app/gitlab.py
index 665af00c19d..5b0435250df 100644
--- a/apps/api/plane/authentication/views/app/gitlab.py
+++ b/apps/api/plane/authentication/views/app/gitlab.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import uuid
diff --git a/apps/api/plane/authentication/views/app/google.py b/apps/api/plane/authentication/views/app/google.py
index 0ee81c768cd..3dad1385a85 100644
--- a/apps/api/plane/authentication/views/app/google.py
+++ b/apps/api/plane/authentication/views/app/google.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import uuid
diff --git a/apps/api/plane/authentication/views/app/magic.py b/apps/api/plane/authentication/views/app/magic.py
index 518a5cdea49..9104311a620 100644
--- a/apps/api/plane/authentication/views/app/magic.py
+++ b/apps/api/plane/authentication/views/app/magic.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.core.validators import validate_email
from django.http import HttpResponseRedirect
diff --git a/apps/api/plane/authentication/views/app/password_management.py b/apps/api/plane/authentication/views/app/password_management.py
index de0baa71b53..48b54dcccb4 100644
--- a/apps/api/plane/authentication/views/app/password_management.py
+++ b/apps/api/plane/authentication/views/app/password_management.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
from urllib.parse import urlencode, urljoin
@@ -141,8 +145,8 @@ def post(self, request, uidb64, token):
results = zxcvbn(password)
if results["score"] < 3:
exc = AuthenticationException(
- error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
- error_message="INVALID_PASSWORD",
+ error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
+ error_message="PASSWORD_TOO_WEAK",
)
url = urljoin(
base_host(request=request, is_app=True),
diff --git a/apps/api/plane/authentication/views/app/signout.py b/apps/api/plane/authentication/views/app/signout.py
index b8019dac188..9941da3c9fc 100644
--- a/apps/api/plane/authentication/views/app/signout.py
+++ b/apps/api/plane/authentication/views/app/signout.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.views import View
from django.contrib.auth import logout
diff --git a/apps/api/plane/authentication/views/common.py b/apps/api/plane/authentication/views/common.py
index c5dd1714c5e..086d6b0d3e2 100644
--- a/apps/api/plane/authentication/views/common.py
+++ b/apps/api/plane/authentication/views/common.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.shortcuts import render
@@ -79,8 +83,8 @@ def post(self, request):
results = zxcvbn(new_password)
if results["score"] < 3:
exc = AuthenticationException(
- error_code=AUTHENTICATION_ERROR_CODES["INVALID_NEW_PASSWORD"],
- error_message="INVALID_NEW_PASSWORD",
+ error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
+ error_message="PASSWORD_TOO_WEAK",
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
diff --git a/apps/api/plane/authentication/views/space/check.py b/apps/api/plane/authentication/views/space/check.py
index 95a5e68dfa2..371fadf3666 100644
--- a/apps/api/plane/authentication/views/space/check.py
+++ b/apps/api/plane/authentication/views/space/check.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
diff --git a/apps/api/plane/authentication/views/space/email.py b/apps/api/plane/authentication/views/space/email.py
index 3d092591add..827348cef23 100644
--- a/apps/api/plane/authentication/views/space/email.py
+++ b/apps/api/plane/authentication/views/space/email.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
diff --git a/apps/api/plane/authentication/views/space/gitea.py b/apps/api/plane/authentication/views/space/gitea.py
index 497a1ecc095..04c21678fe0 100644
--- a/apps/api/plane/authentication/views/space/gitea.py
+++ b/apps/api/plane/authentication/views/space/gitea.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import uuid
from urllib.parse import urlencode
diff --git a/apps/api/plane/authentication/views/space/github.py b/apps/api/plane/authentication/views/space/github.py
index f12498d3b07..1df6a8c619d 100644
--- a/apps/api/plane/authentication/views/space/github.py
+++ b/apps/api/plane/authentication/views/space/github.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import uuid
diff --git a/apps/api/plane/authentication/views/space/gitlab.py b/apps/api/plane/authentication/views/space/gitlab.py
index 498916b3441..19c057a0696 100644
--- a/apps/api/plane/authentication/views/space/gitlab.py
+++ b/apps/api/plane/authentication/views/space/gitlab.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import uuid
diff --git a/apps/api/plane/authentication/views/space/google.py b/apps/api/plane/authentication/views/space/google.py
index 0f02c1f93e8..daa1b48a6f8 100644
--- a/apps/api/plane/authentication/views/space/google.py
+++ b/apps/api/plane/authentication/views/space/google.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import uuid
diff --git a/apps/api/plane/authentication/views/space/magic.py b/apps/api/plane/authentication/views/space/magic.py
index df940b3275e..37683d9acf2 100644
--- a/apps/api/plane/authentication/views/space/magic.py
+++ b/apps/api/plane/authentication/views/space/magic.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.core.validators import validate_email
from django.http import HttpResponseRedirect
diff --git a/apps/api/plane/authentication/views/space/password_management.py b/apps/api/plane/authentication/views/space/password_management.py
index 12cc88f63e7..ed6682d74ae 100644
--- a/apps/api/plane/authentication/views/space/password_management.py
+++ b/apps/api/plane/authentication/views/space/password_management.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
from urllib.parse import urlencode
@@ -135,8 +139,8 @@ def post(self, request, uidb64, token):
results = zxcvbn(password)
if results["score"] < 3:
exc = AuthenticationException(
- error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
- error_message="INVALID_PASSWORD",
+ error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
+ error_message="PASSWORD_TOO_WEAK",
)
url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501
return HttpResponseRedirect(url)
diff --git a/apps/api/plane/authentication/views/space/signout.py b/apps/api/plane/authentication/views/space/signout.py
index aa890f978d4..164c6409bca 100644
--- a/apps/api/plane/authentication/views/space/signout.py
+++ b/apps/api/plane/authentication/views/space/signout.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.views import View
from django.contrib.auth import logout
diff --git a/apps/api/plane/bgtasks/__init__.py b/apps/api/plane/bgtasks/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/bgtasks/__init__.py
+++ b/apps/api/plane/bgtasks/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/bgtasks/analytic_plot_export.py b/apps/api/plane/bgtasks/analytic_plot_export.py
index 845fb50dd24..4b0983138be 100644
--- a/apps/api/plane/bgtasks/analytic_plot_export.py
+++ b/apps/api/plane/bgtasks/analytic_plot_export.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import csv
import io
@@ -9,7 +13,6 @@
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
-from django.utils.html import strip_tags
from django.db.models import Q, Case, Value, When
from django.db import models
from django.db.models.functions import Concat
@@ -18,8 +21,10 @@
from plane.db.models import Issue
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.analytics_plot import build_graph_plot
+from plane.utils.email import generate_plain_text_from_html
from plane.utils.exception_logger import log_exception
from plane.utils.issue_filters import issue_filters
+from plane.utils.csv_utils import sanitize_csv_row
row_mapping = {
"state__name": "State",
@@ -48,7 +53,7 @@ def send_export_email(email, slug, csv_buffer, rows):
"""Helper function to send export email."""
subject = "Your Export is ready"
html_content = render_to_string("emails/exports/analytics.html", {})
- text_content = strip_tags(html_content)
+ text_content = generate_plain_text_from_html(html_content)
csv_buffer.seek(0)
@@ -176,7 +181,7 @@ def generate_csv_from_rows(rows):
"""Generate CSV buffer from rows."""
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
- [writer.writerow(row) for row in rows]
+ [writer.writerow(sanitize_csv_row(row)) for row in rows]
return csv_buffer
diff --git a/apps/api/plane/bgtasks/apps.py b/apps/api/plane/bgtasks/apps.py
index 7f6ca38f0c5..e5fb0aa5479 100644
--- a/apps/api/plane/bgtasks/apps.py
+++ b/apps/api/plane/bgtasks/apps.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.apps import AppConfig
diff --git a/apps/api/plane/bgtasks/cleanup_task.py b/apps/api/plane/bgtasks/cleanup_task.py
index 6b23f2571d5..407a67ca699 100644
--- a/apps/api/plane/bgtasks/cleanup_task.py
+++ b/apps/api/plane/bgtasks/cleanup_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
from datetime import timedelta
import logging
diff --git a/apps/api/plane/bgtasks/copy_s3_object.py b/apps/api/plane/bgtasks/copy_s3_object.py
index e7ef09e353a..742966a6fbb 100644
--- a/apps/api/plane/bgtasks/copy_s3_object.py
+++ b/apps/api/plane/bgtasks/copy_s3_object.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import uuid
import base64
@@ -141,7 +145,7 @@ def copy_s3_objects_of_description_and_assets(entity_name, entity_identifier, pr
external_data = sync_with_external_service(entity_name, updated_html)
if external_data:
- entity.description = external_data.get("description")
+ entity.description_json = external_data.get("description_json")
entity.description_binary = base64.b64decode(external_data.get("description_binary"))
entity.save()
diff --git a/apps/api/plane/bgtasks/deletion_task.py b/apps/api/plane/bgtasks/deletion_task.py
index 932a1fce06f..11d90416063 100644
--- a/apps/api/plane/bgtasks/deletion_task.py
+++ b/apps/api/plane/bgtasks/deletion_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.utils import timezone
from django.apps import apps
diff --git a/apps/api/plane/bgtasks/dummy_data_task.py b/apps/api/plane/bgtasks/dummy_data_task.py
index 390bc160b5f..6740495d83e 100644
--- a/apps/api/plane/bgtasks/dummy_data_task.py
+++ b/apps/api/plane/bgtasks/dummy_data_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import uuid
import random
diff --git a/apps/api/plane/bgtasks/email_notification_task.py b/apps/api/plane/bgtasks/email_notification_task.py
index 1402adc41f8..5cf1d52af91 100644
--- a/apps/api/plane/bgtasks/email_notification_task.py
+++ b/apps/api/plane/bgtasks/email_notification_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import logging
import re
from datetime import datetime
@@ -11,12 +15,12 @@
# Django imports
from django.utils import timezone
-from django.utils.html import strip_tags
# Module imports
from plane.db.models import EmailNotificationLog, Issue, User
from plane.license.utils.instance_value import get_email_configuration
from plane.settings.redis import redis_instance
+from plane.utils.email import generate_plain_text_from_html
from plane.utils.exception_logger import log_exception
@@ -256,7 +260,7 @@ def send_email_notification(issue_id, notification_data, receiver_id, email_noti
"entity_type": "issue",
}
html_content = render_to_string("emails/notifications/issue-updates.html", context)
- text_content = strip_tags(html_content)
+ text_content = generate_plain_text_from_html(html_content)
try:
connection = get_connection(
diff --git a/apps/api/plane/bgtasks/event_tracking_task.py b/apps/api/plane/bgtasks/event_tracking_task.py
index 0629db93af2..e8f453e9fff 100644
--- a/apps/api/plane/bgtasks/event_tracking_task.py
+++ b/apps/api/plane/bgtasks/event_tracking_task.py
@@ -1,5 +1,11 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
+import logging
import os
import uuid
+from typing import Dict, Any
# third party imports
from celery import shared_task
@@ -8,6 +14,11 @@
# module imports
from plane.license.utils.instance_value import get_configuration_value
from plane.utils.exception_logger import log_exception
+from plane.db.models import Workspace
+from plane.utils.analytics_events import USER_INVITED_TO_WORKSPACE, WORKSPACE_DELETED
+
+
+logger = logging.getLogger("plane.worker")
def posthogConfiguration():
@@ -17,7 +28,10 @@ def posthogConfiguration():
"key": "POSTHOG_API_KEY",
"default": os.environ.get("POSTHOG_API_KEY", None),
},
- {"key": "POSTHOG_HOST", "default": os.environ.get("POSTHOG_HOST", None)},
+ {
+ "key": "POSTHOG_HOST",
+ "default": os.environ.get("POSTHOG_HOST", None),
+ },
]
)
if POSTHOG_API_KEY and POSTHOG_HOST:
@@ -26,46 +40,42 @@ def posthogConfiguration():
return None, None
-@shared_task
-def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
- try:
- POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()
+def preprocess_data_properties(
+ user_id: uuid.UUID, event_name: str, slug: str, data_properties: Dict[str, Any]
+) -> Dict[str, Any]:
+ if event_name == USER_INVITED_TO_WORKSPACE or event_name == WORKSPACE_DELETED:
+ try:
+ # Check if the current user is the workspace owner
+ workspace = Workspace.objects.get(slug=slug)
+ if str(workspace.owner_id) == str(user_id):
+ data_properties["role"] = "owner"
+ else:
+ data_properties["role"] = "admin"
+ except Workspace.DoesNotExist:
+ logger.warning(f"Workspace {slug} does not exist while sending event {event_name} for user {user_id}")
+ data_properties["role"] = "unknown"
- if POSTHOG_API_KEY and POSTHOG_HOST:
- posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST)
- posthog.capture(
- email,
- event=event_name,
- properties={
- "event_id": uuid.uuid4().hex,
- "user": {"email": email, "id": str(user)},
- "device_ctx": {"ip": ip, "user_agent": user_agent},
- "medium": medium,
- "first_time": first_time,
- },
- )
- except Exception as e:
- log_exception(e)
- return
+ return data_properties
@shared_task
-def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from):
- try:
- POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()
+def track_event(user_id: uuid.UUID, event_name: str, slug: str, event_properties: Dict[str, Any]):
+ POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()
- if POSTHOG_API_KEY and POSTHOG_HOST:
- posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST)
- posthog.capture(
- email,
- event=event_name,
- properties={
- "event_id": uuid.uuid4().hex,
- "user": {"email": email, "id": str(user)},
- "device_ctx": {"ip": ip, "user_agent": user_agent},
- "accepted_from": accepted_from,
- },
- )
+ if not (POSTHOG_API_KEY and POSTHOG_HOST):
+ logger.warning("Event tracking is not configured")
+ return
+
+ try:
+ # preprocess the data properties for massaging the payload
+ # in the correct format for posthog
+ data_properties = preprocess_data_properties(user_id, event_name, slug, event_properties)
+ groups = {
+ "workspace": slug,
+ }
+ # track the event using posthog
+ posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST)
+ posthog.capture(distinct_id=str(user_id), event=event_name, properties=data_properties, groups=groups)
except Exception as e:
log_exception(e)
- return
+ return False
diff --git a/apps/api/plane/bgtasks/export_task.py b/apps/api/plane/bgtasks/export_task.py
index 75b5f22659e..24486999d73 100644
--- a/apps/api/plane/bgtasks/export_task.py
+++ b/apps/api/plane/bgtasks/export_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import io
import zipfile
@@ -15,9 +19,10 @@
from django.db.models import Prefetch
# Module imports
-from plane.db.models import ExporterHistory, Issue, IssueRelation
+from plane.db.models import ExporterHistory, Issue, IssueComment, IssueRelation, IssueSubscriber
from plane.utils.exception_logger import log_exception
-from plane.utils.exporters import Exporter, IssueExportSchema
+from plane.utils.porters.exporter import DataExporter
+from plane.utils.porters.serializers.issue import IssueExportSerializer
def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO:
@@ -159,10 +164,16 @@ def issue_export_task(
"labels",
"issue_cycle__cycle",
"issue_module__module",
- "issue_comments",
"assignees",
- "issue_subscribers",
"issue_link",
+ Prefetch(
+ "issue_subscribers",
+ queryset=IssueSubscriber.objects.select_related("subscriber"),
+ ),
+ Prefetch(
+ "issue_comments",
+ queryset=IssueComment.objects.select_related("actor").order_by("created_at"),
+ ),
Prefetch(
"issue_relation",
queryset=IssueRelation.objects.select_related("related_issue", "related_issue__project"),
@@ -180,11 +191,7 @@ def issue_export_task(
# Create exporter for the specified format
try:
- exporter = Exporter(
- format_type=provider,
- schema_class=IssueExportSchema,
- options={"list_joiner": ", "},
- )
+ exporter = DataExporter(IssueExportSerializer, format_type=provider)
except ValueError as e:
# Invalid format type
exporter_instance = ExporterHistory.objects.get(token=token_id)
diff --git a/apps/api/plane/bgtasks/exporter_expired_task.py b/apps/api/plane/bgtasks/exporter_expired_task.py
index 30b638c84c8..9ec2a0102ab 100644
--- a/apps/api/plane/bgtasks/exporter_expired_task.py
+++ b/apps/api/plane/bgtasks/exporter_expired_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import boto3
from datetime import timedelta
diff --git a/apps/api/plane/bgtasks/file_asset_task.py b/apps/api/plane/bgtasks/file_asset_task.py
index d6eccf73574..e54a754c9ac 100644
--- a/apps/api/plane/bgtasks/file_asset_task.py
+++ b/apps/api/plane/bgtasks/file_asset_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
from datetime import timedelta
diff --git a/apps/api/plane/bgtasks/forgot_password_task.py b/apps/api/plane/bgtasks/forgot_password_task.py
index ffaba9937f0..9ca0548de2b 100644
--- a/apps/api/plane/bgtasks/forgot_password_task.py
+++ b/apps/api/plane/bgtasks/forgot_password_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import logging
@@ -8,10 +12,10 @@
# Third party imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
-from django.utils.html import strip_tags
# Module imports
from plane.license.utils.instance_value import get_email_configuration
+from plane.utils.email import generate_plain_text_from_html
from plane.utils.exception_logger import log_exception
@@ -41,7 +45,7 @@ def forgot_password(first_name, email, uidb64, token, current_site):
html_content = render_to_string("emails/auth/forgot_password.html", context)
- text_content = strip_tags(html_content)
+ text_content = generate_plain_text_from_html(html_content)
connection = get_connection(
host=EMAIL_HOST,
diff --git a/apps/api/plane/bgtasks/issue_activities_task.py b/apps/api/plane/bgtasks/issue_activities_task.py
index a886305fd23..032feb02a60 100644
--- a/apps/api/plane/bgtasks/issue_activities_task.py
+++ b/apps/api/plane/bgtasks/issue_activities_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
diff --git a/apps/api/plane/bgtasks/issue_automation_task.py b/apps/api/plane/bgtasks/issue_automation_task.py
index 1cc303b575e..83a2f72d187 100644
--- a/apps/api/plane/bgtasks/issue_automation_task.py
+++ b/apps/api/plane/bgtasks/issue_automation_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
from datetime import timedelta
diff --git a/apps/api/plane/bgtasks/issue_description_version_sync.py b/apps/api/plane/bgtasks/issue_description_version_sync.py
index d10ebfcbac7..795d5e7efc7 100644
--- a/apps/api/plane/bgtasks/issue_description_version_sync.py
+++ b/apps/api/plane/bgtasks/issue_description_version_sync.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
from typing import Optional
import logging
@@ -59,7 +63,7 @@ def sync_issue_description_version(batch_size=5000, offset=0, countdown=300):
"description_binary",
"description_html",
"description_stripped",
- "description",
+ "description_json",
)[offset:end_offset]
)
@@ -92,7 +96,7 @@ def sync_issue_description_version(batch_size=5000, offset=0, countdown=300):
description_binary=issue.description_binary,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
- description_json=issue.description,
+ description_json=issue.description_json,
)
)
diff --git a/apps/api/plane/bgtasks/issue_description_version_task.py b/apps/api/plane/bgtasks/issue_description_version_task.py
index 06d15705a73..49689e81502 100644
--- a/apps/api/plane/bgtasks/issue_description_version_task.py
+++ b/apps/api/plane/bgtasks/issue_description_version_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from celery import shared_task
from django.db import transaction
from django.utils import timezone
@@ -19,7 +23,7 @@ def should_update_existing_version(
def update_existing_version(version: IssueDescriptionVersion, issue) -> None:
- version.description_json = issue.description
+ version.description_json = issue.description_json
version.description_html = issue.description_html
version.description_binary = issue.description_binary
version.description_stripped = issue.description_stripped
diff --git a/apps/api/plane/bgtasks/issue_version_sync.py b/apps/api/plane/bgtasks/issue_version_sync.py
index 761c26bc2fe..221a5a417a4 100644
--- a/apps/api/plane/bgtasks/issue_version_sync.py
+++ b/apps/api/plane/bgtasks/issue_version_sync.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
from typing import Optional, List, Dict
diff --git a/apps/api/plane/bgtasks/logger_task.py b/apps/api/plane/bgtasks/logger_task.py
new file mode 100644
index 00000000000..4a74e54bc55
--- /dev/null
+++ b/apps/api/plane/bgtasks/logger_task.py
@@ -0,0 +1,100 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
+# Python imports
+import logging
+from typing import Optional, Dict, Any
+
+# Third party imports
+from pymongo.collection import Collection
+from celery import shared_task
+
+# Django imports
+from plane.settings.mongo import MongoConnection
+from plane.utils.exception_logger import log_exception
+from plane.db.models import APIActivityLog
+
+
+logger = logging.getLogger("plane.worker")
+
+
+def get_mongo_collection() -> Optional[Collection]:
+ """
+ Returns the MongoDB collection for external API activity logs.
+ """
+ if not MongoConnection.is_configured():
+ logger.info("MongoDB not configured")
+ return None
+
+ try:
+ return MongoConnection.get_collection("api_activity_logs")
+ except Exception as e:
+ logger.error(f"Error getting MongoDB collection: {str(e)}")
+ log_exception(e)
+ return None
+
+
+def safe_decode_body(content: bytes) -> Optional[str]:
+ """
+ Safely decodes request/response body content, handling binary data.
+ Returns "[Binary Content]" if the content is binary, or a string representation of the content.
+ Returns None if the content is None or empty.
+ """
+ # If the content is None, return None
+ if content is None:
+ return None
+
+ # If the content is an empty bytes object, return None
+ if content == b"":
+ return None
+
+ # Check if content is binary by looking for common binary file signatures
+ if content.startswith(b"\x89PNG") or content.startswith(b"\xff\xd8\xff") or content.startswith(b"%PDF"):
+ return "[Binary Content]"
+
+ try:
+ return content.decode("utf-8")
+ except UnicodeDecodeError:
+ return "[Could not decode content]"
+
+
+def log_to_mongo(log_document: Dict[str, Any]) -> bool:
+ """
+ Logs the request to MongoDB if available.
+ """
+ mongo_collection = get_mongo_collection()
+ if mongo_collection is None:
+ logger.error("MongoDB not configured")
+ return False
+
+ try:
+ mongo_collection.insert_one(log_document)
+ return True
+ except Exception as e:
+ log_exception(e)
+ return False
+
+
+def log_to_postgres(log_data: Dict[str, Any]) -> bool:
+ """
+ Fallback to logging to PostgreSQL if MongoDB is unavailable.
+ """
+ try:
+ APIActivityLog.objects.create(**log_data)
+ return True
+ except Exception as e:
+ log_exception(e)
+ return False
+
+
+@shared_task
+def process_logs(log_data: Dict[str, Any], mongo_log: Dict[str, Any]) -> None:
+ """
+ Process logs to save to MongoDB or Postgres based on the configuration
+ """
+
+ if MongoConnection.is_configured():
+ log_to_mongo(mongo_log)
+ else:
+ log_to_postgres(log_data)
diff --git a/apps/api/plane/bgtasks/magic_link_code_task.py b/apps/api/plane/bgtasks/magic_link_code_task.py
index d8267e69716..eef7adea037 100644
--- a/apps/api/plane/bgtasks/magic_link_code_task.py
+++ b/apps/api/plane/bgtasks/magic_link_code_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import logging
@@ -8,10 +12,10 @@
# Third party imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
-from django.utils.html import strip_tags
# Module imports
from plane.license.utils.instance_value import get_email_configuration
+from plane.utils.email import generate_plain_text_from_html
from plane.utils.exception_logger import log_exception
@@ -33,7 +37,7 @@ def magic_link(email, key, token):
context = {"code": token, "email": email}
html_content = render_to_string("emails/auth/magic_signin.html", context)
- text_content = strip_tags(html_content)
+ text_content = generate_plain_text_from_html(html_content)
connection = get_connection(
host=EMAIL_HOST,
diff --git a/apps/api/plane/bgtasks/notification_task.py b/apps/api/plane/bgtasks/notification_task.py
index 6e571c0b17f..bfb72afa364 100644
--- a/apps/api/plane/bgtasks/notification_task.py
+++ b/apps/api/plane/bgtasks/notification_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
import uuid
diff --git a/apps/api/plane/bgtasks/page_transaction_task.py b/apps/api/plane/bgtasks/page_transaction_task.py
index 402d0a3ee02..8c2cfe7a0cb 100644
--- a/apps/api/plane/bgtasks/page_transaction_task.py
+++ b/apps/api/plane/bgtasks/page_transaction_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import logging
@@ -88,7 +92,6 @@ def page_transaction(new_description_html, old_description_html, page_id):
has_existing_logs = PageLog.objects.filter(page_id=page_id).exists()
-
# Extract all components in a single pass (optimized)
old_components = extract_all_components(old_description_html)
new_components = extract_all_components(new_description_html)
@@ -125,12 +128,9 @@ def page_transaction(new_description_html, old_description_html, page_id):
)
)
-
# Bulk insert and cleanup
if new_transactions:
- PageLog.objects.bulk_create(
- new_transactions, batch_size=50, ignore_conflicts=True
- )
+ PageLog.objects.bulk_create(new_transactions, batch_size=50, ignore_conflicts=True)
if deleted_transaction_ids:
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
diff --git a/apps/api/plane/bgtasks/page_version_task.py b/apps/api/plane/bgtasks/page_version_task.py
index 4de2387becf..7b41e3c445c 100644
--- a/apps/api/plane/bgtasks/page_version_task.py
+++ b/apps/api/plane/bgtasks/page_version_task.py
@@ -1,37 +1,73 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
+
# Third party imports
from celery import shared_task
+# Django imports
+from django.utils import timezone
+
# Module imports
from plane.db.models import Page, PageVersion
from plane.utils.exception_logger import log_exception
+PAGE_VERSION_TASK_TIMEOUT = 600
@shared_task
-def page_version(page_id, existing_instance, user_id):
+def track_page_version(page_id, existing_instance, user_id):
try:
# Get the page
page = Page.objects.get(id=page_id)
# Get the current instance
current_instance = json.loads(existing_instance) if existing_instance is not None else {}
+ sub_pages = {}
+
# Create a version if description_html is updated
if current_instance.get("description_html") != page.description_html:
- # Create a new page version
- PageVersion.objects.create(
- page_id=page_id,
- workspace_id=page.workspace_id,
- description_html=page.description_html,
- description_binary=page.description_binary,
- owned_by_id=user_id,
- last_saved_at=page.updated_at,
- description_json=page.description,
- description_stripped=page.description_stripped,
- )
+ # Fetch the latest page version
+ page_version = PageVersion.objects.filter(page_id=page_id).order_by("-last_saved_at").first()
+ # Get the latest page version if it exists and is owned by the user
+ if (
+ page_version
+ and str(page_version.owned_by_id) == str(user_id)
+ and (timezone.now() - page_version.last_saved_at).total_seconds() <= PAGE_VERSION_TASK_TIMEOUT
+ ):
+ page_version.description_html = page.description_html
+ page_version.description_binary = page.description_binary
+ page_version.description_json = page.description
+ page_version.description_stripped = page.description_stripped
+ page_version.sub_pages_data = sub_pages
+ page_version.save(
+ update_fields=[
+ "description_html",
+ "description_binary",
+ "description_json",
+ "description_stripped",
+ "sub_pages_data",
+ "updated_at"
+ ]
+ )
+ else:
+ # Create a new page version
+ PageVersion.objects.create(
+ page_id=page_id,
+ workspace_id=page.workspace_id,
+ description_json=page.description,
+ description_html=page.description_html,
+ description_binary=page.description_binary,
+ description_stripped=page.description_stripped,
+ owned_by_id=user_id,
+ last_saved_at=timezone.now(),
+ sub_pages_data=sub_pages,
+ )
# If page versions are greater than 20 delete the oldest one
if PageVersion.objects.filter(page_id=page_id).count() > 20:
# Delete the old page version
diff --git a/apps/api/plane/bgtasks/project_add_user_email_task.py b/apps/api/plane/bgtasks/project_add_user_email_task.py
index af6014695d1..1efe6bc4612 100644
--- a/apps/api/plane/bgtasks/project_add_user_email_task.py
+++ b/apps/api/plane/bgtasks/project_add_user_email_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import logging
@@ -7,11 +11,11 @@
# Third party imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
-from django.utils.html import strip_tags
# Module imports
from plane.license.utils.instance_value import get_email_configuration
+from plane.utils.email import generate_plain_text_from_html
from plane.utils.exception_logger import log_exception
from plane.db.models import ProjectMember
from plane.db.models import User
@@ -55,7 +59,7 @@ def project_add_user_email(current_site, project_member_id, invitor_id):
# Render the email template
html_content = render_to_string("emails/notifications/project_addition.html", context)
- text_content = strip_tags(html_content)
+ text_content = generate_plain_text_from_html(html_content)
# Initialize the connection
connection = get_connection(
host=EMAIL_HOST,
diff --git a/apps/api/plane/bgtasks/project_invitation_task.py b/apps/api/plane/bgtasks/project_invitation_task.py
index b8eed5e45a9..86c10e90cb9 100644
--- a/apps/api/plane/bgtasks/project_invitation_task.py
+++ b/apps/api/plane/bgtasks/project_invitation_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import logging
@@ -8,11 +12,11 @@
# Third party imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
-from django.utils.html import strip_tags
# Module imports
from plane.db.models import Project, ProjectMemberInvite, User
from plane.license.utils.instance_value import get_email_configuration
+from plane.utils.email import generate_plain_text_from_html
from plane.utils.exception_logger import log_exception
@@ -33,11 +37,12 @@ def project_invitation(email, project_id, token, current_site, invitor):
"first_name": user.first_name,
"project_name": project.name,
"invitation_url": abs_url,
+ "current_site": current_site,
}
html_content = render_to_string("emails/invitations/project_invitation.html", context)
- text_content = strip_tags(html_content)
+ text_content = generate_plain_text_from_html(html_content)
project_member_invite.message = text_content
project_member_invite.save()
diff --git a/apps/api/plane/bgtasks/recent_visited_task.py b/apps/api/plane/bgtasks/recent_visited_task.py
index eda297ce45b..3d4f9e6e9fc 100644
--- a/apps/api/plane/bgtasks/recent_visited_task.py
+++ b/apps/api/plane/bgtasks/recent_visited_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
from django.utils import timezone
from django.db import DatabaseError
diff --git a/apps/api/plane/bgtasks/storage_metadata_task.py b/apps/api/plane/bgtasks/storage_metadata_task.py
index ea745053f70..77f99e91658 100644
--- a/apps/api/plane/bgtasks/storage_metadata_task.py
+++ b/apps/api/plane/bgtasks/storage_metadata_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third party imports
from celery import shared_task
diff --git a/apps/api/plane/bgtasks/user_activation_email_task.py b/apps/api/plane/bgtasks/user_activation_email_task.py
index 492564b3cec..f7a2d3999ae 100644
--- a/apps/api/plane/bgtasks/user_activation_email_task.py
+++ b/apps/api/plane/bgtasks/user_activation_email_task.py
@@ -1,10 +1,13 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import logging
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
-from django.utils.html import strip_tags
# Third party imports
from celery import shared_task
@@ -12,6 +15,7 @@
# Module imports
from plane.db.models import User
from plane.license.utils.instance_value import get_email_configuration
+from plane.utils.email import generate_plain_text_from_html
from plane.utils.exception_logger import log_exception
@@ -27,7 +31,7 @@ def user_activation_email(current_site, user_id):
# Send email to user
html_content = render_to_string("emails/user/user_activation.html", context)
- text_content = strip_tags(html_content)
+ text_content = generate_plain_text_from_html(html_content)
# Configure email connection from the database
(
EMAIL_HOST,
diff --git a/apps/api/plane/bgtasks/user_deactivation_email_task.py b/apps/api/plane/bgtasks/user_deactivation_email_task.py
index 2595d8055b2..81419606a75 100644
--- a/apps/api/plane/bgtasks/user_deactivation_email_task.py
+++ b/apps/api/plane/bgtasks/user_deactivation_email_task.py
@@ -1,10 +1,13 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import logging
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
-from django.utils.html import strip_tags
# Third party imports
from celery import shared_task
@@ -12,6 +15,7 @@
# Module imports
from plane.db.models import User
from plane.license.utils.instance_value import get_email_configuration
+from plane.utils.email import generate_plain_text_from_html
from plane.utils.exception_logger import log_exception
@@ -27,7 +31,7 @@ def user_deactivation_email(current_site, user_id):
# Send email to user
html_content = render_to_string("emails/user/user_deactivation.html", context)
- text_content = strip_tags(html_content)
+ text_content = generate_plain_text_from_html(html_content)
# Configure email connection from the database
(
EMAIL_HOST,
diff --git a/apps/api/plane/bgtasks/user_email_update_task.py b/apps/api/plane/bgtasks/user_email_update_task.py
index 667de368c79..48b9c02dba2 100644
--- a/apps/api/plane/bgtasks/user_email_update_task.py
+++ b/apps/api/plane/bgtasks/user_email_update_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import logging
@@ -7,10 +11,10 @@
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
-from django.utils.html import strip_tags
# Module imports
from plane.license.utils.instance_value import get_email_configuration
+from plane.utils.email import generate_plain_text_from_html
from plane.utils.exception_logger import log_exception
@@ -32,7 +36,7 @@ def send_email_update_magic_code(email, token):
context = {"code": token, "email": email}
html_content = render_to_string("emails/auth/magic_signin.html", context)
- text_content = strip_tags(html_content)
+ text_content = generate_plain_text_from_html(html_content)
connection = get_connection(
host=EMAIL_HOST,
@@ -83,7 +87,7 @@ def send_email_update_confirmation(email):
context = {"email": email}
html_content = render_to_string("emails/user/email_updated.html", context)
- text_content = strip_tags(html_content)
+ text_content = generate_plain_text_from_html(html_content)
connection = get_connection(
host=EMAIL_HOST,
diff --git a/apps/api/plane/bgtasks/webhook_task.py b/apps/api/plane/bgtasks/webhook_task.py
index 3d04a65b71b..6543c3845b8 100644
--- a/apps/api/plane/bgtasks/webhook_task.py
+++ b/apps/api/plane/bgtasks/webhook_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import hashlib
import hmac
import json
@@ -16,7 +20,6 @@
from django.core.mail import EmailMultiAlternatives, get_connection
from django.core.serializers.json import DjangoJSONEncoder
from django.template.loader import render_to_string
-from django.utils.html import strip_tags
from django.core.exceptions import ObjectDoesNotExist
# Module imports
@@ -47,6 +50,7 @@
IssueAssignee,
)
from plane.license.utils.instance_value import get_email_configuration
+from plane.utils.email import generate_plain_text_from_html
from plane.utils.exception_logger import log_exception
from plane.settings.mongo import MongoConnection
@@ -218,7 +222,7 @@ def send_webhook_deactivation_email(webhook_id: str, receiver_id: str, current_s
"webhook_url": f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}",
}
html_content = render_to_string("emails/notifications/webhook-deactivate.html", context)
- text_content = strip_tags(html_content)
+ text_content = generate_plain_text_from_html(html_content)
# Set the email connection
connection = get_connection(
diff --git a/apps/api/plane/bgtasks/work_item_link_task.py b/apps/api/plane/bgtasks/work_item_link_task.py
index 442396c7f02..5cf0fbb1908 100644
--- a/apps/api/plane/bgtasks/work_item_link_task.py
+++ b/apps/api/plane/bgtasks/work_item_link_task.py
@@ -13,7 +13,7 @@
from urllib.parse import urlparse, urljoin
import base64
import ipaddress
-from typing import Dict, Any
+from typing import Dict, Any, Tuple
from typing import Optional
from plane.db.models import IssueLink
from plane.utils.exception_logger import log_exception
@@ -66,6 +66,52 @@ def validate_url_ip(url: str) -> None:
MAX_REDIRECTS = 5
+def safe_get(
+ url: str,
+ headers: Optional[Dict[str, str]] = None,
+ timeout: int = 1,
+) -> Tuple[requests.Response, str]:
+ """
+ Perform a GET request that validates every redirect hop against private IPs.
+ Prevents SSRF by ensuring no redirect lands on a private/internal address.
+
+ Args:
+ url: The URL to fetch
+ headers: Optional request headers
+ timeout: Request timeout in seconds
+
+ Returns:
+ A tuple of (final Response object, final URL after redirects)
+
+ Raises:
+ ValueError: If any URL in the redirect chain points to a private IP
+ requests.RequestException: On network errors
+ RuntimeError: If max redirects exceeded
+ """
+ validate_url_ip(url)
+
+ current_url = url
+ response = requests.get(
+ current_url, headers=headers, timeout=timeout, allow_redirects=False
+ )
+
+ redirect_count = 0
+ while response.is_redirect:
+ if redirect_count >= MAX_REDIRECTS:
+ raise RuntimeError(f"Too many redirects for URL: {url}")
+ redirect_url = response.headers.get("Location")
+ if not redirect_url:
+ break
+ current_url = urljoin(current_url, redirect_url)
+ validate_url_ip(current_url)
+ redirect_count += 1
+ response = requests.get(
+ current_url, headers=headers, timeout=timeout, allow_redirects=False
+ )
+
+ return response, current_url
+
+
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
"""
Crawls a URL to extract the title and favicon.
@@ -86,26 +132,8 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
title = None
final_url = url
- validate_url_ip(final_url)
-
try:
- # Manually follow redirects to validate each URL before requesting
- redirect_count = 0
- response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
-
- while response.is_redirect and redirect_count < MAX_REDIRECTS:
- redirect_url = response.headers.get("Location")
- if not redirect_url:
- break
- # Resolve relative redirects against current URL
- final_url = urljoin(final_url, redirect_url)
- # Validate the redirect target BEFORE making the request
- validate_url_ip(final_url)
- redirect_count += 1
- response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
-
- if redirect_count >= MAX_REDIRECTS:
- logger.warning(f"Too many redirects for URL: {url}")
+ response, final_url = safe_get(url, headers=headers)
soup = BeautifulSoup(response.content, "html.parser")
title_tag = soup.find("title")
@@ -113,8 +141,10 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
except requests.RequestException as e:
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
+ except (ValueError, RuntimeError) as e:
+ logger.warning(f"URL validation failed: {str(e)}")
- # Fetch and encode favicon using final URL (after redirects)
+ # Fetch and encode favicon using final URL (after redirects) for correct relative href resolution
favicon_base64 = fetch_and_encode_favicon(headers, soup, final_url)
# Prepare result
@@ -204,9 +234,7 @@ def fetch_and_encode_favicon(
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}
- validate_url_ip(favicon_url)
-
- response = requests.get(favicon_url, headers=headers, timeout=1)
+ response, _ = safe_get(favicon_url, headers=headers)
# Get content type
content_type = response.headers.get("content-type", "image/x-icon")
diff --git a/apps/api/plane/bgtasks/workspace_invitation_task.py b/apps/api/plane/bgtasks/workspace_invitation_task.py
index f7480b36a69..f293cc16f8b 100644
--- a/apps/api/plane/bgtasks/workspace_invitation_task.py
+++ b/apps/api/plane/bgtasks/workspace_invitation_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import logging
@@ -7,11 +11,11 @@
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
-from django.utils.html import strip_tags
# Module imports
from plane.db.models import User, Workspace, WorkspaceMemberInvite
from plane.license.utils.instance_value import get_email_configuration
+from plane.utils.email import generate_plain_text_from_html
from plane.utils.exception_logger import log_exception
@@ -25,7 +29,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter):
# Relative link
relative_link = (
- f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" # noqa: E501
+ f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&slug={workspace.slug}&token={token}" # noqa: E501
)
# The complete url including the domain
@@ -53,7 +57,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter):
html_content = render_to_string("emails/invitations/workspace_invitation.html", context)
- text_content = strip_tags(html_content)
+ text_content = generate_plain_text_from_html(html_content)
workspace_member_invite.message = text_content
workspace_member_invite.save()
diff --git a/apps/api/plane/bgtasks/workspace_seed_task.py b/apps/api/plane/bgtasks/workspace_seed_task.py
index 57ac02ec127..218ba2a7179 100644
--- a/apps/api/plane/bgtasks/workspace_seed_task.py
+++ b/apps/api/plane/bgtasks/workspace_seed_task.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
import json
@@ -21,7 +25,7 @@
WorkspaceMember,
Project,
ProjectMember,
- IssueUserProperty,
+ ProjectUserProperty,
State,
Label,
Issue,
@@ -94,7 +98,7 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int,
project_seed.pop("name", None)
project_seed.pop("identifier", None)
- project = Project.objects.create(
+ project = Project(
**project_seed,
workspace=workspace,
name=workspace.name, # Use workspace name
@@ -105,58 +109,63 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int,
module_view=True,
issue_views_view=True,
)
+ project.save(created_by_id=bot_user.id, disable_auto_set_user=True)
# Create project members
- ProjectMember.objects.bulk_create([
- ProjectMember(
- project=project,
- member_id=workspace_member["member_id"],
- role=workspace_member["role"],
- workspace_id=workspace.id,
- created_by_id=bot_user.id,
- )
- for workspace_member in workspace_members
- ])
+ ProjectMember.objects.bulk_create(
+ [
+ ProjectMember(
+ project=project,
+ member_id=workspace_member["member_id"],
+ role=workspace_member["role"],
+ workspace_id=workspace.id,
+ created_by_id=bot_user.id,
+ )
+ for workspace_member in workspace_members
+ ]
+ )
# Create issue user properties
- IssueUserProperty.objects.bulk_create([
- IssueUserProperty(
- project=project,
- user_id=workspace_member["member_id"],
- workspace_id=workspace.id,
- display_filters={
- "layout": "list",
- "calendar": {"layout": "month", "show_weekends": False},
- "group_by": "state",
- "order_by": "sort_order",
- "sub_issue": True,
- "sub_group_by": None,
- "show_empty_groups": True,
- },
- display_properties={
- "key": True,
- "link": True,
- "cycle": False,
- "state": True,
- "labels": False,
- "modules": False,
- "assignee": True,
- "due_date": False,
- "estimate": True,
- "priority": True,
- "created_on": True,
- "issue_type": True,
- "start_date": False,
- "updated_on": True,
- "customer_count": True,
- "sub_issue_count": False,
- "attachment_count": False,
- "customer_request_count": True,
- },
- created_by_id=bot_user.id,
- )
- for workspace_member in workspace_members
- ])
+ ProjectUserProperty.objects.bulk_create(
+ [
+ ProjectUserProperty(
+ project=project,
+ user_id=workspace_member["member_id"],
+ workspace_id=workspace.id,
+ display_filters={
+ "layout": "list",
+ "calendar": {"layout": "month", "show_weekends": False},
+ "group_by": "state",
+ "order_by": "sort_order",
+ "sub_issue": True,
+ "sub_group_by": None,
+ "show_empty_groups": True,
+ },
+ display_properties={
+ "key": True,
+ "link": True,
+ "cycle": False,
+ "state": True,
+ "labels": False,
+ "modules": False,
+ "assignee": True,
+ "due_date": False,
+ "estimate": True,
+ "priority": True,
+ "created_on": True,
+ "issue_type": True,
+ "start_date": False,
+ "updated_on": True,
+ "customer_count": True,
+ "sub_issue_count": False,
+ "attachment_count": False,
+ "customer_request_count": True,
+ },
+ created_by_id=bot_user.id,
+ )
+ for workspace_member in workspace_members
+ ]
+ )
# update map
projects_map[project_id] = project.id
logger.info(f"Task: workspace_seed_task -> Project {project_id} created")
@@ -187,13 +196,13 @@ def create_project_states(
state_id = state_seed.pop("id")
project_id = state_seed.pop("project_id")
- state = State.objects.create(
+ state = State(
**state_seed,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=bot_user.id,
)
-
+ state.save(created_by_id=bot_user.id, disable_auto_set_user=True)
state_map[state_id] = state.id
logger.info(f"Task: workspace_seed_task -> State {state_id} created")
return state_map
@@ -220,12 +229,13 @@ def create_project_labels(
for label_seed in label_seeds:
label_id = label_seed.pop("id")
project_id = label_seed.pop("project_id")
- label = Label.objects.create(
+ label = Label(
**label_seed,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=bot_user.id,
)
+ label.save(created_by_id=bot_user.id, disable_auto_set_user=True)
label_map[label_id] = label.id
logger.info(f"Task: workspace_seed_task -> Label {label_id} created")
@@ -272,13 +282,14 @@ def create_project_issues(
cycle_id = issue_seed.pop("cycle_id")
module_ids = issue_seed.pop("module_ids")
- issue = Issue.objects.create(
+ issue = Issue(
**issue_seed,
state_id=states_map[state_id],
project_id=project_map[project_id],
workspace=workspace,
created_by_id=bot_user.id,
)
+ issue.save(created_by_id=bot_user.id, disable_auto_set_user=True)
IssueSequence.objects.create(
issue=issue,
project_id=project_map[project_id],
@@ -347,12 +358,12 @@ def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_us
for page_seed in page_seeds:
page_id = page_seed.pop("id")
- page = Page.objects.create(
+ page = Page(
workspace_id=workspace.id,
is_global=False,
access=page_seed.get("access", Page.PUBLIC_ACCESS),
name=page_seed.get("name"),
- description=page_seed.get("description", {}),
+ description_json=page_seed.get("description_json", {}),
description_html=page_seed.get("description_html", ""),
description_binary=page_seed.get("description_binary", None),
description_stripped=page_seed.get("description_stripped", None),
@@ -361,16 +372,18 @@ def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_us
owned_by_id=bot_user.id,
)
+ page.save(created_by_id=bot_user.id, disable_auto_set_user=True)
+
logger.info(f"Task: workspace_seed_task -> Page {page_id} created")
if page_seed.get("project_id") and page_seed.get("type") == "PROJECT":
- ProjectPage.objects.create(
+ project_page = ProjectPage(
workspace_id=workspace.id,
project_id=project_map[page_seed.get("project_id")],
page_id=page.id,
created_by_id=bot_user.id,
updated_by_id=bot_user.id,
)
-
+ project_page.save(created_by_id=bot_user.id, disable_auto_set_user=True)
logger.info(f"Task: workspace_seed_task -> Project Page {page_id} created")
return
@@ -410,7 +423,7 @@ def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_u
start_date = timezone.now() + timedelta(days=14)
end_date = start_date + timedelta(days=14)
- cycle = Cycle.objects.create(
+ cycle = Cycle(
**cycle_seed,
start_date=start_date,
end_date=end_date,
@@ -419,6 +432,7 @@ def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_u
created_by_id=bot_user.id,
owned_by_id=bot_user.id,
)
+ cycle.save(created_by_id=bot_user.id, disable_auto_set_user=True)
cycle_map[cycle_id] = cycle.id
logger.info(f"Task: workspace_seed_task -> Cycle {cycle_id} created")
@@ -446,7 +460,7 @@ def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_
start_date = timezone.now() + timedelta(days=index * 2)
end_date = start_date + timedelta(days=14)
- module = Module.objects.create(
+ module = Module(
**module_seed,
start_date=start_date,
target_date=end_date,
@@ -454,6 +468,7 @@ def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_
workspace=workspace,
created_by_id=bot_user.id,
)
+ module.save(created_by_id=bot_user.id, disable_auto_set_user=True)
module_map[module_id] = module.id
logger.info(f"Task: workspace_seed_task -> Module {module_id} created")
return module_map
@@ -474,13 +489,15 @@ def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_us
for view_seed in view_seeds:
project_id = view_seed.pop("project_id")
- IssueView.objects.create(
+ view_seed.pop("id")
+ issue_view = IssueView(
**view_seed,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=bot_user.id,
owned_by_id=bot_user.id,
)
+ issue_view.save(created_by_id=bot_user.id, disable_auto_set_user=True)
@shared_task
@@ -514,6 +531,14 @@ def workspace_seed(workspace_id: uuid.UUID) -> None:
is_password_autoset=True,
)
+ # Add bot user to workspace as member
+ WorkspaceMember.objects.create(
+ workspace=workspace,
+ member=bot_user,
+ role=20,
+ company_role="",
+ )
+
# Create a project with the same name as workspace
project_map = create_project_and_member(workspace, bot_user)
diff --git a/apps/api/plane/celery.py b/apps/api/plane/celery.py
index 828f4a6d595..562d04856f5 100644
--- a/apps/api/plane/celery.py
+++ b/apps/api/plane/celery.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
import logging
diff --git a/apps/api/plane/db/__init__.py b/apps/api/plane/db/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/db/__init__.py
+++ b/apps/api/plane/db/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/db/apps.py b/apps/api/plane/db/apps.py
index 7d4919d088b..92c55908e99 100644
--- a/apps/api/plane/db/apps.py
+++ b/apps/api/plane/db/apps.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.apps import AppConfig
diff --git a/apps/api/plane/db/management/__init__.py b/apps/api/plane/db/management/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/db/management/__init__.py
+++ b/apps/api/plane/db/management/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/db/management/commands/__init__.py b/apps/api/plane/db/management/commands/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/db/management/commands/__init__.py
+++ b/apps/api/plane/db/management/commands/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/db/management/commands/activate_user.py b/apps/api/plane/db/management/commands/activate_user.py
index 5ebe8b74094..3488a986591 100644
--- a/apps/api/plane/db/management/commands/activate_user.py
+++ b/apps/api/plane/db/management/commands/activate_user.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.core.management import BaseCommand, CommandError
diff --git a/apps/api/plane/db/management/commands/clear_cache.py b/apps/api/plane/db/management/commands/clear_cache.py
index 1c66b3eafcf..502778f1cfb 100644
--- a/apps/api/plane/db/management/commands/clear_cache.py
+++ b/apps/api/plane/db/management/commands/clear_cache.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.core.cache import cache
from django.core.management import BaseCommand
diff --git a/apps/api/plane/db/management/commands/copy_issue_comment_to_description.py b/apps/api/plane/db/management/commands/copy_issue_comment_to_description.py
index 8813f34db2b..ec106795b4e 100644
--- a/apps/api/plane/db/management/commands/copy_issue_comment_to_description.py
+++ b/apps/api/plane/db/management/commands/copy_issue_comment_to_description.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.core.management.base import BaseCommand
from django.db import transaction
diff --git a/apps/api/plane/db/management/commands/create_bucket.py b/apps/api/plane/db/management/commands/create_bucket.py
index 555fe0aa88e..7a39a3a7fcf 100644
--- a/apps/api/plane/db/management/commands/create_bucket.py
+++ b/apps/api/plane/db/management/commands/create_bucket.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
import boto3
diff --git a/apps/api/plane/db/management/commands/create_dummy_data.py b/apps/api/plane/db/management/commands/create_dummy_data.py
index 220576b8f49..c85c1e01763 100644
--- a/apps/api/plane/db/management/commands/create_dummy_data.py
+++ b/apps/api/plane/db/management/commands/create_dummy_data.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from typing import Any
from django.core.management.base import BaseCommand, CommandError
diff --git a/apps/api/plane/db/management/commands/create_instance_admin.py b/apps/api/plane/db/management/commands/create_instance_admin.py
index 8d5a912e042..3834918d409 100644
--- a/apps/api/plane/db/management/commands/create_instance_admin.py
+++ b/apps/api/plane/db/management/commands/create_instance_admin.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.core.management.base import BaseCommand, CommandError
diff --git a/apps/api/plane/db/management/commands/create_project_member.py b/apps/api/plane/db/management/commands/create_project_member.py
index d9b46524c28..2bd97557874 100644
--- a/apps/api/plane/db/management/commands/create_project_member.py
+++ b/apps/api/plane/db/management/commands/create_project_member.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from typing import Any
from django.core.management import BaseCommand, CommandError
@@ -8,7 +12,7 @@
WorkspaceMember,
ProjectMember,
Project,
- IssueUserProperty,
+ ProjectUserProperty,
)
@@ -47,27 +51,18 @@ def handle(self, *args: Any, **options: Any):
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}"))
diff --git a/apps/api/plane/db/management/commands/fix_duplicate_sequences.py b/apps/api/plane/db/management/commands/fix_duplicate_sequences.py
index 2b262606a29..70624fbc283 100644
--- a/apps/api/plane/db/management/commands/fix_duplicate_sequences.py
+++ b/apps/api/plane/db/management/commands/fix_duplicate_sequences.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.core.management.base import BaseCommand, CommandError
from django.db.models import Max
diff --git a/apps/api/plane/db/management/commands/reset_password.py b/apps/api/plane/db/management/commands/reset_password.py
index 9e483f51e3d..5da607c6cd4 100644
--- a/apps/api/plane/db/management/commands/reset_password.py
+++ b/apps/api/plane/db/management/commands/reset_password.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import getpass
diff --git a/apps/api/plane/db/management/commands/sync_issue_description_version.py b/apps/api/plane/db/management/commands/sync_issue_description_version.py
index 04e608a3ce1..0aac4bb1531 100644
--- a/apps/api/plane/db/management/commands/sync_issue_description_version.py
+++ b/apps/api/plane/db/management/commands/sync_issue_description_version.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.core.management.base import BaseCommand
diff --git a/apps/api/plane/db/management/commands/sync_issue_version.py b/apps/api/plane/db/management/commands/sync_issue_version.py
index 6c9a2cdac1c..a7ee98fa751 100644
--- a/apps/api/plane/db/management/commands/sync_issue_version.py
+++ b/apps/api/plane/db/management/commands/sync_issue_version.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.core.management.base import BaseCommand
diff --git a/apps/api/plane/db/management/commands/test_email.py b/apps/api/plane/db/management/commands/test_email.py
index 22841a671b8..103b239b1aa 100644
--- a/apps/api/plane/db/management/commands/test_email.py
+++ b/apps/api/plane/db/management/commands/test_email.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.core.mail import EmailMultiAlternatives, get_connection
from django.core.management import BaseCommand, CommandError
from django.template.loader import render_to_string
diff --git a/apps/api/plane/db/management/commands/update_bucket.py b/apps/api/plane/db/management/commands/update_bucket.py
index 47c28ff739d..79f7eab4e7c 100644
--- a/apps/api/plane/db/management/commands/update_bucket.py
+++ b/apps/api/plane/db/management/commands/update_bucket.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
import boto3
diff --git a/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py b/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py
index 8383253541e..067afe23166 100644
--- a/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py
+++ b/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.core.management.base import BaseCommand
from django.db import transaction
from plane.db.models import Workspace
diff --git a/apps/api/plane/db/management/commands/wait_for_db.py b/apps/api/plane/db/management/commands/wait_for_db.py
index ec971f83a77..8a9fdbc3d6f 100644
--- a/apps/api/plane/db/management/commands/wait_for_db.py
+++ b/apps/api/plane/db/management/commands/wait_for_db.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import time
from django.db import connections
from django.db.utils import OperationalError
diff --git a/apps/api/plane/db/management/commands/wait_for_migrations.py b/apps/api/plane/db/management/commands/wait_for_migrations.py
index 13b251de53c..b61d011b25c 100644
--- a/apps/api/plane/db/management/commands/wait_for_migrations.py
+++ b/apps/api/plane/db/management/commands/wait_for_migrations.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# wait_for_migrations.py
import time
from django.core.management.base import BaseCommand
diff --git a/apps/api/plane/db/migrations/0113_webhook_version.py b/apps/api/plane/db/migrations/0113_webhook_version.py
new file mode 100644
index 00000000000..4ffbf3bb7fc
--- /dev/null
+++ b/apps/api/plane/db/migrations/0113_webhook_version.py
@@ -0,0 +1,66 @@
+# Generated by Django 4.2.26 on 2025-12-15 10:29
+
+from django.db import migrations, models
+import plane.db.models.workspace
+
+
+def set_default_product_tour_to_false():
+ return {
+ "work_items": False,
+ "cycles": False,
+ "modules": False,
+ "intake": False,
+ "pages": False,
+ }
+
+def get_default_product_tour():
+ return {
+ "work_items": True,
+ "cycles": True,
+ "modules": True,
+ "intake": True,
+ "pages": True,
+ }
+
+
+def populate_product_tour(apps, _schema_editor):
+ WorkspaceUserProperties = apps.get_model('db', 'WorkspaceUserProperties')
+ default_value = get_default_product_tour()
+ # Use bulk update for better performance
+ WorkspaceUserProperties.objects.all().update(product_tour=default_value)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('db', '0112_auto_20251124_0603'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='webhook',
+ name='version',
+ field=models.CharField(default='v1', max_length=50),
+ ),
+ migrations.AddField(
+ model_name='profile',
+ name='is_navigation_tour_completed',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name='workspaceuserproperties',
+ name='product_tour',
+ field=models.JSONField(default=set_default_product_tour_to_false),
+ ),
+ migrations.AddField(
+ model_name='apitoken',
+ name='allowed_rate_limit',
+ field=models.CharField(default='60/min', max_length=255),
+ ),
+ migrations.AddField(
+ model_name='profile',
+ name='is_subscribed_to_changelog',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.RunPython(populate_product_tour, reverse_code=migrations.RunPython.noop),
+ ]
diff --git a/apps/api/plane/db/migrations/0114_projectuserproperty_delete_issueuserproperty_and_more.py b/apps/api/plane/db/migrations/0114_projectuserproperty_delete_issueuserproperty_and_more.py
new file mode 100644
index 00000000000..9a18fbafca5
--- /dev/null
+++ b/apps/api/plane/db/migrations/0114_projectuserproperty_delete_issueuserproperty_and_more.py
@@ -0,0 +1,50 @@
+# Generated by Django 4.2.22 on 2026-01-05 08:35
+
+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', '0113_webhook_version'),
+ ]
+
+ operations = [
+ migrations.AlterModelTable(
+ name='issueuserproperty',
+ table='project_user_properties',
+ ),
+ migrations.RenameModel(
+ old_name='IssueUserProperty',
+ new_name='ProjectUserProperty',
+ ),
+ 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'),
+ ),
+ ]
\ No newline at end of file
diff --git a/apps/api/plane/db/migrations/0115_auto_20260105_1406.py b/apps/api/plane/db/migrations/0115_auto_20260105_1406.py
new file mode 100644
index 00000000000..b9ac71d4709
--- /dev/null
+++ b/apps/api/plane/db/migrations/0115_auto_20260105_1406.py
@@ -0,0 +1,51 @@
+# Generated by Django 4.2.22 on 2026-01-05 08:36
+
+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', '0114_projectuserproperty_delete_issueuserproperty_and_more'),
+ ]
+
+ 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),
+ ]
diff --git a/apps/api/plane/db/migrations/0116_workspacemember_explored_features_and_more.py b/apps/api/plane/db/migrations/0116_workspacemember_explored_features_and_more.py
new file mode 100644
index 00000000000..38e231e0eb2
--- /dev/null
+++ b/apps/api/plane/db/migrations/0116_workspacemember_explored_features_and_more.py
@@ -0,0 +1,38 @@
+# Generated by Django 4.2.27 on 2026-01-13 10:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('db', '0115_auto_20260105_1406'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='profile',
+ name='notification_view_mode',
+ field=models.CharField(choices=[('full', 'Full'), ('compact', 'Compact')], default='full', max_length=255),
+ ),
+ migrations.AddField(
+ model_name='user',
+ name='is_password_reset_required',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name='workspacemember',
+ name='explored_features',
+ field=models.JSONField(default=dict),
+ ),
+ migrations.AddField(
+ model_name='workspacemember',
+ name='getting_started_checklist',
+ field=models.JSONField(default=dict),
+ ),
+ migrations.AddField(
+ model_name='workspacemember',
+ name='tips',
+ field=models.JSONField(default=dict),
+ ),
+ ]
diff --git a/apps/api/plane/db/migrations/0117_rename_description_draftissue_description_json_and_more.py b/apps/api/plane/db/migrations/0117_rename_description_draftissue_description_json_and_more.py
new file mode 100644
index 00000000000..2317a4cdd77
--- /dev/null
+++ b/apps/api/plane/db/migrations/0117_rename_description_draftissue_description_json_and_more.py
@@ -0,0 +1,28 @@
+# Generated by Django 4.2.22 on 2026-01-15 09:02
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('db', '0116_workspacemember_explored_features_and_more'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='draftissue',
+ old_name='description',
+ new_name='description_json',
+ ),
+ migrations.RenameField(
+ model_name='issue',
+ old_name='description',
+ new_name='description_json',
+ ),
+ migrations.RenameField(
+ model_name='page',
+ old_name='description',
+ new_name='description_json',
+ ),
+ ]
diff --git a/apps/api/plane/db/migrations/0118_remove_workspaceuserproperties_product_tour_and_more.py b/apps/api/plane/db/migrations/0118_remove_workspaceuserproperties_product_tour_and_more.py
new file mode 100644
index 00000000000..9a2b39edfc1
--- /dev/null
+++ b/apps/api/plane/db/migrations/0118_remove_workspaceuserproperties_product_tour_and_more.py
@@ -0,0 +1,71 @@
+# Generated by Django 4.2.27 on 2026-01-23 09:27
+
+from django.db import migrations, models
+import plane.db.models.user
+
+def set_getting_started_checklist():
+ return {
+ "project_created": True,
+ "project_joined": True,
+ "work_item_created": True,
+ "team_members_invited": True,
+ "page_created": True,
+ "ai_chat_tried": True,
+ "integration_linked": True,
+ "view_created": True,
+ "sticky_created": True,
+ }
+
+def set_default_tips():
+ return {"mobile_app_download": True}
+
+
+def set_default_explored_features():
+ return {"github_integrated": True, "slack_integrated": True, "ai_chat_tried": True}
+
+
+def set_default_product_tour():
+ return {
+ "work_items": True,
+ "cycles": True,
+ "modules": True,
+ "intake": True,
+ "pages": True,
+ }
+
+
+def migrate_all_the_product_tour_to_true(apps, _schema_editor):
+ Profile = apps.get_model('db', 'Profile')
+ WorkspaceMember = apps.get_model('db', 'WorkspaceMember')
+
+ default_checklist_values = set_getting_started_checklist()
+ default_tips_values = set_default_tips()
+ default_explored_features = set_default_explored_features()
+ default_product_tour = set_default_product_tour()
+
+ Profile.objects.all().update(is_navigation_tour_completed=True)
+ WorkspaceMember.objects.all().update(getting_started_checklist=default_checklist_values)
+ WorkspaceMember.objects.all().update(tips=default_tips_values)
+ WorkspaceMember.objects.all().update(explored_features=default_explored_features)
+ Profile.objects.all().update(product_tour=default_product_tour)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('db', '0117_rename_description_draftissue_description_json_and_more'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='workspaceuserproperties',
+ name='product_tour',
+ ),
+ migrations.AddField(
+ model_name='profile',
+ name='product_tour',
+ field=models.JSONField(default=plane.db.models.user.get_default_product_tour),
+ ),
+ migrations.RunPython(migrate_all_the_product_tour_to_true, reverse_code=migrations.RunPython.noop)
+
+ ]
diff --git a/apps/api/plane/db/migrations/0119_alter_estimatepoint_key.py b/apps/api/plane/db/migrations/0119_alter_estimatepoint_key.py
new file mode 100644
index 00000000000..a730808a163
--- /dev/null
+++ b/apps/api/plane/db/migrations/0119_alter_estimatepoint_key.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.2.27 on 2026-02-09 09:37
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('db', '0118_remove_workspaceuserproperties_product_tour_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='estimatepoint',
+ name='key',
+ field=models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)]),
+ ),
+ ]
diff --git a/apps/api/plane/db/migrations/0120_issueview_archived_at.py b/apps/api/plane/db/migrations/0120_issueview_archived_at.py
new file mode 100644
index 00000000000..4357766d448
--- /dev/null
+++ b/apps/api/plane/db/migrations/0120_issueview_archived_at.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.28 on 2026-02-17 10:47
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('db', '0119_alter_estimatepoint_key'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='issueview',
+ name='archived_at',
+ field=models.DateTimeField(null=True),
+ ),
+ ]
diff --git a/apps/api/plane/db/migrations/0121_alter_estimate_type.py b/apps/api/plane/db/migrations/0121_alter_estimate_type.py
new file mode 100644
index 00000000000..73b75123f63
--- /dev/null
+++ b/apps/api/plane/db/migrations/0121_alter_estimate_type.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.28 on 2026-02-26 14:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('db', '0120_issueview_archived_at'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='estimate',
+ name='type',
+ field=models.CharField(choices=[('categories', 'Categories'), ('points', 'Points')], default='categories', max_length=255),
+ ),
+ ]
diff --git a/apps/api/plane/db/mixins.py b/apps/api/plane/db/mixins.py
index be5613b613f..b36269959b5 100644
--- a/apps/api/plane/db/mixins.py
+++ b/apps/api/plane/db/mixins.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Type imports
from typing import Any
@@ -188,3 +192,30 @@ def old_values(self) -> dict[str, Any]:
all non-deferred fields).
"""
return self._original_values
+
+ def save(self, *args: Any, **kwargs: Any) -> None:
+ """
+ Override save to automatically capture changed fields and reset tracking.
+
+ Before saving, the current changed_fields are captured and stored in
+ _changes_on_save. After saving, the tracked fields are reset so
+ that subsequent saves correctly detect changes relative to the last
+ saved state, not the original load-time state.
+
+ Models that need to access the changed fields after save (e.g., for
+ syncing related models) can use self._changes_on_save.
+ """
+ self._changes_on_save = self.changed_fields
+ super().save(*args, **kwargs)
+ self._reset_tracked_fields()
+
+ def _reset_tracked_fields(self) -> None:
+ """
+ Reset the tracked field values to the current state.
+
+ This is called automatically after save() to ensure that subsequent
+ saves correctly detect changes relative to the last saved state,
+ rather than the original load-time state.
+ """
+ self._original_values = {}
+ self._track_fields()
diff --git a/apps/api/plane/db/models/__init__.py b/apps/api/plane/db/models/__init__.py
index 41fd32bd557..5cf9dec2a3e 100644
--- a/apps/api/plane/db/models/__init__.py
+++ b/apps/api/plane/db/models/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from .analytic import AnalyticView
from .api import APIActivityLog, APIToken
from .asset import FileAsset
@@ -34,7 +38,6 @@
IssueLabel,
IssueLink,
IssueMention,
- IssueUserProperty,
IssueReaction,
IssueRelation,
IssueSequence,
@@ -54,6 +57,7 @@
ProjectMemberInvite,
ProjectNetwork,
ProjectPublicMember,
+ ProjectUserProperty,
)
from .session import Session
from .social_connection import SocialLoginConnection
diff --git a/apps/api/plane/db/models/analytic.py b/apps/api/plane/db/models/analytic.py
index 0efcb957f4e..601ef9ea542 100644
--- a/apps/api/plane/db/models/analytic.py
+++ b/apps/api/plane/db/models/analytic.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django models
from django.db import models
diff --git a/apps/api/plane/db/models/api.py b/apps/api/plane/db/models/api.py
index 7d040ebc284..c545860c058 100644
--- a/apps/api/plane/db/models/api.py
+++ b/apps/api/plane/db/models/api.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
from uuid import uuid4
@@ -32,6 +36,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"
diff --git a/apps/api/plane/db/models/asset.py b/apps/api/plane/db/models/asset.py
index ed9879a7331..d309135bcac 100644
--- a/apps/api/plane/db/models/asset.py
+++ b/apps/api/plane/db/models/asset.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
from uuid import uuid4
diff --git a/apps/api/plane/db/models/base.py b/apps/api/plane/db/models/base.py
index 468af826141..482dc90635a 100644
--- a/apps/api/plane/db/models/base.py
+++ b/apps/api/plane/db/models/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import uuid
# Django imports
diff --git a/apps/api/plane/db/models/cycle.py b/apps/api/plane/db/models/cycle.py
index bdffd283d8f..78ea977d911 100644
--- a/apps/api/plane/db/models/cycle.py
+++ b/apps/api/plane/db/models/cycle.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import pytz
diff --git a/apps/api/plane/db/models/deploy_board.py b/apps/api/plane/db/models/deploy_board.py
index da9c0d69826..b9d8778e08e 100644
--- a/apps/api/plane/db/models/deploy_board.py
+++ b/apps/api/plane/db/models/deploy_board.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
from uuid import uuid4
diff --git a/apps/api/plane/db/models/description.py b/apps/api/plane/db/models/description.py
index 6c298546a39..0e8de3ce76c 100644
--- a/apps/api/plane/db/models/description.py
+++ b/apps/api/plane/db/models/description.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.db import models
from django.utils.html import strip_tags
from .workspace import WorkspaceBaseModel
diff --git a/apps/api/plane/db/models/device.py b/apps/api/plane/db/models/device.py
index adcf7974a12..9254a21ffe9 100644
--- a/apps/api/plane/db/models/device.py
+++ b/apps/api/plane/db/models/device.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# models.py
from django.db import models
from django.conf import settings
diff --git a/apps/api/plane/db/models/draft.py b/apps/api/plane/db/models/draft.py
index 55dbb61df94..2d126da2289 100644
--- a/apps/api/plane/db/models/draft.py
+++ b/apps/api/plane/db/models/draft.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.conf import settings
from django.db import models
@@ -39,7 +43,7 @@ class DraftIssue(WorkspaceBaseModel):
blank=True,
)
name = models.CharField(max_length=255, verbose_name="Issue Name", blank=True, null=True)
- description = models.JSONField(blank=True, default=dict)
+ description_json = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="")
description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True)
diff --git a/apps/api/plane/db/models/estimate.py b/apps/api/plane/db/models/estimate.py
index 9373fb3204c..fb472a69bd4 100644
--- a/apps/api/plane/db/models/estimate.py
+++ b/apps/api/plane/db/models/estimate.py
@@ -1,16 +1,24 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
-from django.core.validators import MaxValueValidator, MinValueValidator
+from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q
# Module imports
from .project import ProjectBaseModel
+class EstimateType(models.TextChoices):
+ CATEGORIES = "categories", "Categories"
+ POINTS = "points", "Points"
+
class Estimate(ProjectBaseModel):
name = models.CharField(max_length=255)
description = models.TextField(verbose_name="Estimate Description", blank=True)
- type = models.CharField(max_length=255, default="categories")
+ type = models.CharField(max_length=255, choices=EstimateType.choices, default=EstimateType.CATEGORIES)
last_used = models.BooleanField(default=False)
def __str__(self):
@@ -34,7 +42,7 @@ class Meta:
class EstimatePoint(ProjectBaseModel):
estimate = models.ForeignKey("db.Estimate", on_delete=models.CASCADE, related_name="points")
- key = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(12)])
+ key = models.IntegerField(default=0, validators=[MinValueValidator(0)])
description = models.TextField(blank=True)
value = models.CharField(max_length=255)
diff --git a/apps/api/plane/db/models/exporter.py b/apps/api/plane/db/models/exporter.py
index 8ad9daad7af..7abfe63afd4 100644
--- a/apps/api/plane/db/models/exporter.py
+++ b/apps/api/plane/db/models/exporter.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import uuid
# Python imports
diff --git a/apps/api/plane/db/models/favorite.py b/apps/api/plane/db/models/favorite.py
index de2b101a05d..1ce29da8750 100644
--- a/apps/api/plane/db/models/favorite.py
+++ b/apps/api/plane/db/models/favorite.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.conf import settings
# Django imports
diff --git a/apps/api/plane/db/models/importer.py b/apps/api/plane/db/models/importer.py
index 9bcea8cf0bd..24d987bb7a3 100644
--- a/apps/api/plane/db/models/importer.py
+++ b/apps/api/plane/db/models/importer.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.conf import settings
from django.db import models
diff --git a/apps/api/plane/db/models/intake.py b/apps/api/plane/db/models/intake.py
index c3369ae1d05..700d5d8cf74 100644
--- a/apps/api/plane/db/models/intake.py
+++ b/apps/api/plane/db/models/intake.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.db import models
diff --git a/apps/api/plane/db/models/integration/__init__.py b/apps/api/plane/db/models/integration/__init__.py
index 34b40e57d98..2242b4ddd14 100644
--- a/apps/api/plane/db/models/integration/__init__.py
+++ b/apps/api/plane/db/models/integration/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from .base import Integration, WorkspaceIntegration
from .github import (
GithubRepository,
diff --git a/apps/api/plane/db/models/integration/base.py b/apps/api/plane/db/models/integration/base.py
index 296c3cf6d67..d98aa292df6 100644
--- a/apps/api/plane/db/models/integration/base.py
+++ b/apps/api/plane/db/models/integration/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import uuid
diff --git a/apps/api/plane/db/models/integration/github.py b/apps/api/plane/db/models/integration/github.py
index ba278497edf..8d84dbe3e46 100644
--- a/apps/api/plane/db/models/integration/github.py
+++ b/apps/api/plane/db/models/integration/github.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
# Django imports
diff --git a/apps/api/plane/db/models/integration/slack.py b/apps/api/plane/db/models/integration/slack.py
index 1e8ea469b6d..f1c33f5c2cf 100644
--- a/apps/api/plane/db/models/integration/slack.py
+++ b/apps/api/plane/db/models/integration/slack.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
# Django imports
diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py
index d3377f0ad37..d24efc8a23c 100644
--- a/apps/api/plane/db/models/issue.py
+++ b/apps/api/plane/db/models/issue.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python import
from uuid import uuid4
@@ -90,14 +94,6 @@ def get_queryset(self):
return (
super()
.get_queryset()
- .filter(
- models.Q(issue_intake__status=1)
- | models.Q(issue_intake__status=-1)
- | models.Q(issue_intake__status=2)
- | models.Q(issue_intake__isnull=True)
- )
- .filter(deleted_at__isnull=True)
- .filter(state__is_triage=False)
.exclude(state__group=StateGroup.TRIAGE.value)
.exclude(archived_at__isnull=False)
.exclude(project__archived_at__isnull=False)
@@ -136,7 +132,7 @@ class Issue(ProjectBaseModel):
blank=True,
)
name = models.CharField(max_length=255, verbose_name="Issue Name")
- description = models.JSONField(blank=True, default=dict)
+ description_json = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="")
description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True)
@@ -207,39 +203,35 @@ def save(self, *args, **kwargs):
if self._state.adding:
with transaction.atomic():
- # Create a lock for this specific project using an advisory lock
+ # Create a lock for this specific project using a transaction-level advisory lock
# This ensures only one transaction per project can execute this code at a time
+ # The lock is automatically released when the transaction ends
lock_key = convert_uuid_to_integer(self.project.id)
with connection.cursor() as cursor:
- # Get an exclusive lock using the project ID as the lock key
- cursor.execute("SELECT pg_advisory_lock(%s)", [lock_key])
-
- try:
- # Get the last sequence for the project
- last_sequence = IssueSequence.objects.filter(project=self.project).aggregate(
- largest=models.Max("sequence")
- )["largest"]
- self.sequence_id = last_sequence + 1 if last_sequence else 1
- # Strip the html tags using html parser
- self.description_stripped = (
- None
- if (self.description_html == "" or self.description_html is None)
- else strip_tags(self.description_html)
- )
- largest_sort_order = Issue.objects.filter(project=self.project, state=self.state).aggregate(
- largest=models.Max("sort_order")
- )["largest"]
- if largest_sort_order is not None:
- self.sort_order = largest_sort_order + 10000
-
- super(Issue, self).save(*args, **kwargs)
-
- IssueSequence.objects.create(issue=self, sequence=self.sequence_id, project=self.project)
- finally:
- # Release the lock
- with connection.cursor() as cursor:
- cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key])
+ # Get an exclusive transaction-level lock using the project ID as the lock key
+ cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key])
+
+ # Get the last sequence for the project
+ last_sequence = IssueSequence.objects.filter(project=self.project).aggregate(
+ largest=models.Max("sequence")
+ )["largest"]
+ self.sequence_id = last_sequence + 1 if last_sequence else 1
+ # Strip the html tags using html parser
+ self.description_stripped = (
+ None
+ if (self.description_html == "" or self.description_html is None)
+ else strip_tags(self.description_html)
+ )
+ largest_sort_order = Issue.objects.filter(project=self.project, state=self.state).aggregate(
+ largest=models.Max("sort_order")
+ )["largest"]
+ if largest_sort_order is not None:
+ self.sort_order = largest_sort_order + 10000
+
+ super(Issue, self).save(*args, **kwargs)
+
+ IssueSequence.objects.create(issue=self, sequence=self.sequence_id, project=self.project)
else:
# Strip the html tags using html parser
self.description_stripped = (
@@ -513,10 +505,12 @@ def save(self, *args, **kwargs):
"comment_json": "description_json",
}
+ # Use _changes_on_save which is captured by ChangeTrackerMixin.save()
+ # before the tracked fields are reset
changed_fields = {
desc_field: getattr(self, comment_field)
for comment_field, desc_field in field_mapping.items()
- if self.has_changed(comment_field)
+ if comment_field in self._changes_on_save
}
# Update description only if comment fields changed
@@ -536,36 +530,6 @@ def __str__(self):
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")
@@ -840,7 +804,7 @@ def log_issue_description_version(cls, issue, user):
description_binary=issue.description_binary,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
- description_json=issue.description,
+ description_json=issue.description_json,
)
return True
except Exception as e:
diff --git a/apps/api/plane/db/models/issue_type.py b/apps/api/plane/db/models/issue_type.py
index 4f3dc08deca..94eaf50bfe0 100644
--- a/apps/api/plane/db/models/issue_type.py
+++ b/apps/api/plane/db/models/issue_type.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.db import models
from django.db.models import Q
diff --git a/apps/api/plane/db/models/label.py b/apps/api/plane/db/models/label.py
index 76ecf10e615..9435e01c655 100644
--- a/apps/api/plane/db/models/label.py
+++ b/apps/api/plane/db/models/label.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.db import models
from django.db.models import Q
diff --git a/apps/api/plane/db/models/module.py b/apps/api/plane/db/models/module.py
index ab62f2df540..d660116fa83 100644
--- a/apps/api/plane/db/models/module.py
+++ b/apps/api/plane/db/models/module.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.conf import settings
from django.db import models
diff --git a/apps/api/plane/db/models/notification.py b/apps/api/plane/db/models/notification.py
index fd97a3c9689..c2413585484 100644
--- a/apps/api/plane/db/models/notification.py
+++ b/apps/api/plane/db/models/notification.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.conf import settings
from django.db import models
diff --git a/apps/api/plane/db/models/page.py b/apps/api/plane/db/models/page.py
index 213954d1498..2c82c5f44b2 100644
--- a/apps/api/plane/db/models/page.py
+++ b/apps/api/plane/db/models/page.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import uuid
from django.conf import settings
@@ -25,7 +29,7 @@ class Page(BaseModel):
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="pages")
name = models.TextField(blank=True)
- description = models.JSONField(default=dict, blank=True)
+ description_json = models.JSONField(default=dict, blank=True)
description_binary = models.BinaryField(null=True)
description_html = models.TextField(blank=True, default="")
description_stripped = models.TextField(blank=True, null=True)
diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py
index 8495ac9df43..4039b1d2903 100644
--- a/apps/api/plane/db/models/project.py
+++ b/apps/api/plane/db/models/project.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import pytz
from uuid import uuid4
@@ -12,7 +16,6 @@
# Module imports
from plane.db.mixins import AuditModel
-# Module imports
from .base import BaseModel
ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest"))
@@ -116,6 +119,11 @@ class Project(BaseModel):
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
+ def __init__(self, *args, **kwargs):
+ # Track if timezone is provided, if so, don't override it with the workspace timezone when saving
+ self.is_timezone_provided = kwargs.get("timezone") is not None
+ super().__init__(*args, **kwargs)
+
@property
def cover_image_url(self):
# Return cover image url
@@ -132,6 +140,8 @@ def __str__(self):
"""Return name of the project"""
return f"{self.name} <{self.workspace.name}>"
+ FORBIDDEN_IDENTIFIER_CHARS_PATTERN = r"^.*[&+,:;$^}{*=?@#|'<>.()%!-].*$"
+
class Meta:
unique_together = [
["identifier", "workspace", "deleted_at"],
@@ -155,7 +165,15 @@ class Meta:
ordering = ("-created_at",)
def save(self, *args, **kwargs):
+ from plane.db.models import Workspace
+
self.identifier = self.identifier.strip().upper()
+ is_creating = self._state.adding
+
+ if is_creating and not self.is_timezone_provided:
+ workspace = Workspace.objects.get(id=self.workspace_id)
+ self.timezone = workspace.timezone
+
return super().save(*args, **kwargs)
@@ -206,14 +224,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"]
-
- # Project ordering
- if smallest_sort_order is not None:
- self.sort_order = smallest_sort_order - 10000
+ 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")
+
+ # 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 +337,37 @@ class Meta:
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)
diff --git a/apps/api/plane/db/models/recent_visit.py b/apps/api/plane/db/models/recent_visit.py
index 42855081bd1..fb368fa1226 100644
--- a/apps/api/plane/db/models/recent_visit.py
+++ b/apps/api/plane/db/models/recent_visit.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.db import models
from django.conf import settings
diff --git a/apps/api/plane/db/models/session.py b/apps/api/plane/db/models/session.py
index e884498bf12..52b885ee94e 100644
--- a/apps/api/plane/db/models/session.py
+++ b/apps/api/plane/db/models/session.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import string
diff --git a/apps/api/plane/db/models/social_connection.py b/apps/api/plane/db/models/social_connection.py
index 9a85a320d5c..7e8ee8c2cac 100644
--- a/apps/api/plane/db/models/social_connection.py
+++ b/apps/api/plane/db/models/social_connection.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.conf import settings
from django.db import models
diff --git a/apps/api/plane/db/models/state.py b/apps/api/plane/db/models/state.py
index aeb08b8b2e9..fa56900c3fe 100644
--- a/apps/api/plane/db/models/state.py
+++ b/apps/api/plane/db/models/state.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.db import models
from django.template.defaultfilters import slugify
@@ -5,7 +9,7 @@
# Module imports
from .project import ProjectBaseModel
-
+from plane.db.mixins import SoftDeletionManager
class StateGroup(models.TextChoices):
BACKLOG = "backlog", "Backlog"
@@ -58,14 +62,14 @@ class StateGroup(models.TextChoices):
]
-class StateManager(models.Manager):
+class StateManager(SoftDeletionManager):
"""Default manager - excludes triage states"""
def get_queryset(self):
return super().get_queryset().exclude(group=StateGroup.TRIAGE.value)
-class TriageStateManager(models.Manager):
+class TriageStateManager(SoftDeletionManager):
"""Manager for triage states only"""
def get_queryset(self):
diff --git a/apps/api/plane/db/models/sticky.py b/apps/api/plane/db/models/sticky.py
index 157077eb8c1..757cb8ea114 100644
--- a/apps/api/plane/db/models/sticky.py
+++ b/apps/api/plane/db/models/sticky.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.conf import settings
from django.db import models
diff --git a/apps/api/plane/db/models/user.py b/apps/api/plane/db/models/user.py
index ee70032cf42..7f1ab162dab 100644
--- a/apps/api/plane/db/models/user.py
+++ b/apps/api/plane/db/models/user.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import random
import string
@@ -35,6 +39,16 @@ def get_mobile_default_onboarding():
}
+def get_default_product_tour():
+ return {
+ "work_items": False,
+ "cycles": False,
+ "modules": False,
+ "intake": False,
+ "pages": False,
+ }
+
+
class BotTypeEnum(models.TextChoices):
WORKSPACE_SEED = "WORKSPACE_SEED", "Workspace Seed"
@@ -84,7 +98,7 @@ class User(AbstractBaseUser, PermissionsMixin):
is_staff = models.BooleanField(default=False)
is_email_verified = models.BooleanField(default=False)
is_password_autoset = models.BooleanField(default=False)
-
+ is_password_reset_required = models.BooleanField(default=False)
# random token generated
token = models.CharField(max_length=64, blank=True)
@@ -147,6 +161,11 @@ def cover_image_url(self):
return self.cover_image
return None
+ @property
+ def full_name(self):
+ """Return user's full name (first + last)."""
+ return f"{self.first_name} {self.last_name}".strip()
+
def save(self, *args, **kwargs):
self.email = self.email.lower().strip()
self.mobile_number = self.mobile_number
@@ -167,6 +186,16 @@ def save(self, *args, **kwargs):
super(User, self).save(*args, **kwargs)
+ @classmethod
+ def get_display_name(cls, email):
+ if not email:
+ return "".join(random.choice(string.ascii_letters) for _ in range(6))
+ return (
+ email.split("@")[0]
+ if len(email.split("@")) == 2
+ else "".join(random.choice(string.ascii_letters) for _ in range(6))
+ )
+
class Profile(TimeAuditModel):
SUNDAY = 0
@@ -177,6 +206,10 @@ class Profile(TimeAuditModel):
FRIDAY = 5
SATURDAY = 6
+ class NotificationViewMode(models.TextChoices):
+ FULL = "full", "Full"
+ COMPACT = "compact", "Compact"
+
START_OF_THE_WEEK_CHOICES = (
(SUNDAY, "Sunday"),
(MONDAY, "Monday"),
@@ -206,7 +239,9 @@ class Profile(TimeAuditModel):
billing_address = models.JSONField(null=True)
has_billing_address = models.BooleanField(default=False)
company_name = models.CharField(max_length=255, blank=True)
-
+ notification_view_mode = models.CharField(
+ max_length=255, choices=NotificationViewMode.choices, default=NotificationViewMode.FULL
+ )
is_smooth_cursor_enabled = models.BooleanField(default=False)
# mobile
is_mobile_onboarded = models.BooleanField(default=False)
@@ -218,8 +253,13 @@ class Profile(TimeAuditModel):
goals = models.JSONField(default=dict)
background_color = models.CharField(max_length=255, default=get_random_color)
+ # navigation tour
+ is_navigation_tour_completed = models.BooleanField(default=False)
+
# marketing
has_marketing_email_consent = models.BooleanField(default=False)
+ is_subscribed_to_changelog = models.BooleanField(default=False)
+ product_tour = models.JSONField(default=get_default_product_tour)
class Meta:
verbose_name = "Profile"
diff --git a/apps/api/plane/db/models/view.py b/apps/api/plane/db/models/view.py
index d430cd5f97e..a02b768a39a 100644
--- a/apps/api/plane/db/models/view.py
+++ b/apps/api/plane/db/models/view.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.conf import settings
from django.db import models
@@ -64,6 +68,7 @@ class IssueView(WorkspaceBaseModel):
logo_props = models.JSONField(default=dict)
owned_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="views")
is_locked = models.BooleanField(default=False)
+ archived_at = models.DateTimeField(null=True)
class Meta:
verbose_name = "Issue View"
diff --git a/apps/api/plane/db/models/webhook.py b/apps/api/plane/db/models/webhook.py
index 8872d0bb235..99431ed4225 100644
--- a/apps/api/plane/db/models/webhook.py
+++ b/apps/api/plane/db/models/webhook.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
from uuid import uuid4
from urllib.parse import urlparse
@@ -38,6 +42,7 @@ class Webhook(BaseModel):
cycle = models.BooleanField(default=False)
issue_comment = models.BooleanField(default=False)
is_internal = models.BooleanField(default=False)
+ version = models.CharField(default="v1", max_length=50)
def __str__(self):
return f"{self.workspace.slug} {self.url}"
diff --git a/apps/api/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py
index d3470d531ea..80a3e3e3e42 100644
--- a/apps/api/plane/db/models/workspace.py
+++ b/apps/api/plane/db/models/workspace.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import pytz
from typing import Optional, Any
@@ -204,6 +208,9 @@ class WorkspaceMember(BaseModel):
default_props = models.JSONField(default=get_default_props)
issue_props = models.JSONField(default=get_issue_props)
is_active = models.BooleanField(default=True)
+ getting_started_checklist = models.JSONField(default=dict)
+ tips = models.JSONField(default=dict)
+ explored_features = models.JSONField(default=dict)
class Meta:
unique_together = ["workspace", "member", "deleted_at"]
diff --git a/apps/api/plane/license/__init__.py b/apps/api/plane/license/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/license/__init__.py
+++ b/apps/api/plane/license/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/license/api/__init__.py b/apps/api/plane/license/api/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/license/api/__init__.py
+++ b/apps/api/plane/license/api/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/license/api/permissions/__init__.py b/apps/api/plane/license/api/permissions/__init__.py
index d5bedc4c082..8878e2aaf82 100644
--- a/apps/api/plane/license/api/permissions/__init__.py
+++ b/apps/api/plane/license/api/permissions/__init__.py
@@ -1 +1,5 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from .instance import InstanceAdminPermission
diff --git a/apps/api/plane/license/api/permissions/instance.py b/apps/api/plane/license/api/permissions/instance.py
index a430b688b70..819757375d3 100644
--- a/apps/api/plane/license/api/permissions/instance.py
+++ b/apps/api/plane/license/api/permissions/instance.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third party imports
from rest_framework.permissions import BasePermission
diff --git a/apps/api/plane/license/api/serializers/__init__.py b/apps/api/plane/license/api/serializers/__init__.py
index 6e0a5941c40..b4a39adcef5 100644
--- a/apps/api/plane/license/api/serializers/__init__.py
+++ b/apps/api/plane/license/api/serializers/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from .instance import InstanceSerializer
from .configuration import InstanceConfigurationSerializer
diff --git a/apps/api/plane/license/api/serializers/admin.py b/apps/api/plane/license/api/serializers/admin.py
index 4df6901cac9..ebca0e5622e 100644
--- a/apps/api/plane/license/api/serializers/admin.py
+++ b/apps/api/plane/license/api/serializers/admin.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Module imports
from .base import BaseSerializer
from plane.db.models import User
diff --git a/apps/api/plane/license/api/serializers/base.py b/apps/api/plane/license/api/serializers/base.py
index 0c6bba46823..63c173e6d44 100644
--- a/apps/api/plane/license/api/serializers/base.py
+++ b/apps/api/plane/license/api/serializers/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from rest_framework import serializers
diff --git a/apps/api/plane/license/api/serializers/configuration.py b/apps/api/plane/license/api/serializers/configuration.py
index 1766f21136d..21abc7013a5 100644
--- a/apps/api/plane/license/api/serializers/configuration.py
+++ b/apps/api/plane/license/api/serializers/configuration.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from .base import BaseSerializer
from plane.license.models import InstanceConfiguration
from plane.license.utils.encryption import decrypt_data
diff --git a/apps/api/plane/license/api/serializers/instance.py b/apps/api/plane/license/api/serializers/instance.py
index c75c62e50f6..1598b3fb689 100644
--- a/apps/api/plane/license/api/serializers/instance.py
+++ b/apps/api/plane/license/api/serializers/instance.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Module imports
from plane.license.models import Instance
from plane.app.serializers import BaseSerializer
diff --git a/apps/api/plane/license/api/serializers/user.py b/apps/api/plane/license/api/serializers/user.py
index c53b4a48489..b5e35ac72de 100644
--- a/apps/api/plane/license/api/serializers/user.py
+++ b/apps/api/plane/license/api/serializers/user.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from .base import BaseSerializer
from plane.db.models import User
diff --git a/apps/api/plane/license/api/serializers/workspace.py b/apps/api/plane/license/api/serializers/workspace.py
index 75dd938e45d..d12473e2047 100644
--- a/apps/api/plane/license/api/serializers/workspace.py
+++ b/apps/api/plane/license/api/serializers/workspace.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third Party Imports
from rest_framework import serializers
diff --git a/apps/api/plane/license/api/views/__init__.py b/apps/api/plane/license/api/views/__init__.py
index 7f30d53fe66..e25276495f1 100644
--- a/apps/api/plane/license/api/views/__init__.py
+++ b/apps/api/plane/license/api/views/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from .instance import InstanceEndpoint, SignUpScreenVisitedEndpoint
diff --git a/apps/api/plane/license/api/views/admin.py b/apps/api/plane/license/api/views/admin.py
index 5b70beab9d1..6217cc87fa4 100644
--- a/apps/api/plane/license/api/views/admin.py
+++ b/apps/api/plane/license/api/views/admin.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
from urllib.parse import urlencode, urljoin
import uuid
@@ -134,8 +138,10 @@ def post(self, request):
},
)
url = urljoin(
- base_host(request=request, is_admin=True, ),
-
+ base_host(
+ request=request,
+ is_admin=True,
+ ),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
@@ -185,8 +191,8 @@ def post(self, request):
results = zxcvbn(password)
if results["score"] < 3:
exc = AuthenticationException(
- error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_PASSWORD"],
- error_message="INVALID_ADMIN_PASSWORD",
+ error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
+ error_message="PASSWORD_TOO_WEAK",
payload={
"email": email,
"first_name": first_name,
diff --git a/apps/api/plane/license/api/views/base.py b/apps/api/plane/license/api/views/base.py
index d209bd6bf27..8d0d39ac387 100644
--- a/apps/api/plane/license/api/views/base.py
+++ b/apps/api/plane/license/api/views/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import zoneinfo
from django.conf import settings
diff --git a/apps/api/plane/license/api/views/configuration.py b/apps/api/plane/license/api/views/configuration.py
index 8bb9535655c..bb9a9e00ee6 100644
--- a/apps/api/plane/license/api/views/configuration.py
+++ b/apps/api/plane/license/api/views/configuration.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
from smtplib import (
SMTPAuthenticationError,
diff --git a/apps/api/plane/license/api/views/instance.py b/apps/api/plane/license/api/views/instance.py
index fed0c5e17e6..a0d52d4912f 100644
--- a/apps/api/plane/license/api/views/instance.py
+++ b/apps/api/plane/license/api/views/instance.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
diff --git a/apps/api/plane/license/api/views/workspace.py b/apps/api/plane/license/api/views/workspace.py
index 5d1a2f24bbd..966b3b3e8f9 100644
--- a/apps/api/plane/license/api/views/workspace.py
+++ b/apps/api/plane/license/api/views/workspace.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third party imports
from rest_framework.response import Response
from rest_framework import status
diff --git a/apps/api/plane/license/apps.py b/apps/api/plane/license/apps.py
index 400e98155ae..0cd4aba3b54 100644
--- a/apps/api/plane/license/apps.py
+++ b/apps/api/plane/license/apps.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.apps import AppConfig
diff --git a/apps/api/plane/license/bgtasks/__init__.py b/apps/api/plane/license/bgtasks/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/license/bgtasks/__init__.py
+++ b/apps/api/plane/license/bgtasks/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/license/bgtasks/tracer.py b/apps/api/plane/license/bgtasks/tracer.py
index 055c45d6c85..f7c04b2a4b2 100644
--- a/apps/api/plane/license/bgtasks/tracer.py
+++ b/apps/api/plane/license/bgtasks/tracer.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third party imports
from celery import shared_task
from opentelemetry import trace
diff --git a/apps/api/plane/license/management/__init__.py b/apps/api/plane/license/management/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/license/management/__init__.py
+++ b/apps/api/plane/license/management/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/license/management/commands/__init__.py b/apps/api/plane/license/management/commands/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/license/management/commands/__init__.py
+++ b/apps/api/plane/license/management/commands/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/license/management/commands/configure_instance.py b/apps/api/plane/license/management/commands/configure_instance.py
index b3e84dd82d8..43026a45543 100644
--- a/apps/api/plane/license/management/commands/configure_instance.py
+++ b/apps/api/plane/license/management/commands/configure_instance.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
diff --git a/apps/api/plane/license/management/commands/register_instance.py b/apps/api/plane/license/management/commands/register_instance.py
index 6717cafd13e..5ad6f7d2017 100644
--- a/apps/api/plane/license/management/commands/register_instance.py
+++ b/apps/api/plane/license/management/commands/register_instance.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
import secrets
diff --git a/apps/api/plane/license/models/__init__.py b/apps/api/plane/license/models/__init__.py
index d495240244b..b1a84d846fb 100644
--- a/apps/api/plane/license/models/__init__.py
+++ b/apps/api/plane/license/models/__init__.py
@@ -1 +1,5 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from .instance import Instance, InstanceAdmin, InstanceConfiguration, InstanceEdition
diff --git a/apps/api/plane/license/models/instance.py b/apps/api/plane/license/models/instance.py
index 1767d8c224d..ff9ebc6b46c 100644
--- a/apps/api/plane/license/models/instance.py
+++ b/apps/api/plane/license/models/instance.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
from enum import Enum
diff --git a/apps/api/plane/license/urls.py b/apps/api/plane/license/urls.py
index 4d306924eaf..844a9e181ee 100644
--- a/apps/api/plane/license/urls.py
+++ b/apps/api/plane/license/urls.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
from plane.license.api.views import (
diff --git a/apps/api/plane/license/utils/__init__.py b/apps/api/plane/license/utils/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/license/utils/__init__.py
+++ b/apps/api/plane/license/utils/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/license/utils/encryption.py b/apps/api/plane/license/utils/encryption.py
index d56766d1e1f..8f43167c15a 100644
--- a/apps/api/plane/license/utils/encryption.py
+++ b/apps/api/plane/license/utils/encryption.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import base64
import hashlib
from django.conf import settings
diff --git a/apps/api/plane/license/utils/instance_value.py b/apps/api/plane/license/utils/instance_value.py
index 8901bc814af..279eb217777 100644
--- a/apps/api/plane/license/utils/instance_value.py
+++ b/apps/api/plane/license/utils/instance_value.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
diff --git a/apps/api/plane/middleware/__init__.py b/apps/api/plane/middleware/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/middleware/__init__.py
+++ b/apps/api/plane/middleware/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/middleware/apps.py b/apps/api/plane/middleware/apps.py
index 9deac8091d3..2037b6aa098 100644
--- a/apps/api/plane/middleware/apps.py
+++ b/apps/api/plane/middleware/apps.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.apps import AppConfig
diff --git a/apps/api/plane/middleware/db_routing.py b/apps/api/plane/middleware/db_routing.py
index 68b5c449160..7aa045a69c9 100644
--- a/apps/api/plane/middleware/db_routing.py
+++ b/apps/api/plane/middleware/db_routing.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
Database routing middleware for read replica selection.
This middleware determines whether database queries should be routed to
diff --git a/apps/api/plane/middleware/logger.py b/apps/api/plane/middleware/logger.py
index d513ee3e36d..b8cf6f9c045 100644
--- a/apps/api/plane/middleware/logger.py
+++ b/apps/api/plane/middleware/logger.py
@@ -1,16 +1,22 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import logging
import time
# Django imports
from django.http import HttpRequest
+from django.utils import timezone
# Third party imports
from rest_framework.request import Request
# Module imports
from plane.utils.ip_address import get_client_ip
-from plane.db.models import APIActivityLog
+from plane.utils.exception_logger import log_exception
+from plane.bgtasks.logger_task import process_logs
api_logger = logging.getLogger("plane.api.request")
@@ -70,6 +76,10 @@ def __call__(self, request):
class APITokenLogMiddleware:
+ """
+ Middleware to log External API requests to MongoDB or PostgreSQL.
+ """
+
def __init__(self, get_response):
self.get_response = get_response
@@ -104,24 +114,41 @@ def _safe_decode_body(self, content):
def process_request(self, request, response, request_body):
api_key_header = "X-Api-Key"
api_key = request.headers.get(api_key_header)
- # If the API key is present, log the request
- if api_key:
- try:
- APIActivityLog.objects.create(
- token_identifier=api_key,
- path=request.path,
- method=request.method,
- query_params=request.META.get("QUERY_STRING", ""),
- headers=str(request.headers),
- body=(self._safe_decode_body(request_body) if request_body else None),
- response_body=(self._safe_decode_body(response.content) if response.content else None),
- response_code=response.status_code,
- ip_address=get_client_ip(request=request),
- user_agent=request.META.get("HTTP_USER_AGENT", None),
- )
-
- except Exception as e:
- api_logger.exception(e)
- # If the token does not exist, you can decide whether to log this as an invalid attempt
+
+ # If the API key is not present, return
+ if not api_key:
+ return
+
+ try:
+ log_data = {
+ "token_identifier": api_key,
+ "path": request.path,
+ "method": request.method,
+ "query_params": request.META.get("QUERY_STRING", ""),
+ "headers": str(request.headers),
+ "body": self._safe_decode_body(request_body) if request_body else None,
+ "response_body": self._safe_decode_body(response.content) if response.content else None,
+ "response_code": response.status_code,
+ "ip_address": get_client_ip(request=request),
+ "user_agent": request.META.get("HTTP_USER_AGENT", None),
+ }
+ user_id = (
+ str(request.user.id)
+ if getattr(request, "user") and getattr(request.user, "is_authenticated", False)
+ else None
+ )
+ # Additional fields for MongoDB
+ mongo_log = {
+ **log_data,
+ "created_at": timezone.now(),
+ "updated_at": timezone.now(),
+ "created_by": user_id,
+ "updated_by": user_id,
+ }
+
+ process_logs.delay(log_data=log_data, mongo_log=mongo_log)
+
+ except Exception as e:
+ log_exception(e)
return None
diff --git a/apps/api/plane/middleware/request_body_size.py b/apps/api/plane/middleware/request_body_size.py
index 9807c571568..c4e014df6fe 100644
--- a/apps/api/plane/middleware/request_body_size.py
+++ b/apps/api/plane/middleware/request_body_size.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.core.exceptions import RequestDataTooBig
from django.http import JsonResponse
diff --git a/apps/api/plane/seeds/data/issues.json b/apps/api/plane/seeds/data/issues.json
index badd0e61154..be966e723aa 100644
--- a/apps/api/plane/seeds/data/issues.json
+++ b/apps/api/plane/seeds/data/issues.json
@@ -3,7 +3,7 @@
"id": 1,
"name": "Welcome to Plane 👋",
"sequence_id": 1,
- "description_html": "Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.
Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.
First thing to try
Look in the Properties section below where it says State: Todo.
Click on it and change it to Done from the dropdown. Alternatively, you can drag and drop the card to the Done column.
",
+ "description_html": "Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.
Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.
First thing to try
Look in the Properties section below where it says State: Todo.
Click on it and change it to Done from the dropdown. Alternatively, you can drag and drop the card to the Done column.
",
"description_stripped": "Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.First thing to tryLook in the Properties section below where it says State: Todo.Click on it and change it to Done from the dropdown. Alternatively, you can drag and drop the card to the Done column.",
"sort_order": 1000,
"state_id": 4,
@@ -17,7 +17,7 @@
"id": 2,
"name": "1. Create Projects 🎯",
"sequence_id": 2,
- "description_html": "
A Project in Plane is where all your work comes together. Think of it as a base that organizes your work items and everything else your team needs to get things done.
Note: This tutorial is already set up as a Project, and these cards you're reading are work items within it!
We're showing you how to create a new project just so you'll know exactly what to do when you're ready to start your own real one.
Look over at the left sidebar and find where it says Projects.
Hover your mouse there and you'll see a little + icon pop up - go ahead and click it!
A modal opens where you can give your project a name and other details.
Notice the Access type options? Public means anyone (except Guest users) can see and join it, while Private keeps it just for those you invite.
Tip: You can also quickly create a new project by using the keyboard shortcut P from anywhere in Plane!
",
+ "description_html": "
A Project in Plane is where all your work comes together. Think of it as a base that organizes your work items and everything else your team needs to get things done.
Note: This tutorial is already set up as a Project, and these cards you're reading are work items within it!
We're showing you how to create a new project just so you'll know exactly what to do when you're ready to start your own real one.
Look over at the left sidebar and find where it says Projects.
Hover your mouse there and you'll see a little + icon pop up - go ahead and click it!
A modal opens where you can give your project a name and other details.
Notice the Access type options? Public means anyone (except Guest users) can see and join it, while Private keeps it just for those you invite.
Tip: You can also quickly create a new project by using the keyboard shortcut P from anywhere in Plane!
",
"sort_order": 2000,
"state_id": 2,
"labels": [2],
@@ -30,7 +30,7 @@
"id": 3,
"name": "2. Invite your team 🤜🤛",
"sequence_id": 3,
- "description_html": "Let's get your teammates on board!
First, you'll need to invite them to your workspace before they can join specific projects:
Click on your workspace name in the top-left corner, then select Settings from the dropdown.
Head over to the Members tab - this is your user management hub. Click Add member on the top right.
Enter your teammate's email address. Select a role for them (Admin, Member or Guest) that determines what they can do in the workspace.
Your team member will get an email invite. Once they've joined your workspace, you can add them to specific projects.
To do this, go to your project's Settings page.
Find the Members section, select your teammate, and assign them a project role - this controls what they can do within just this project.
That's it!
To learn more about user management, see Manage users and roles.
",
+ "description_html": "Let's get your teammates on board!
First, you'll need to invite them to your workspace before they can join specific projects:
Click on your workspace name in the top-left corner, then select Settings from the dropdown.
Head over to the Members tab - this is your user management hub. Click Add member on the top right.
Enter your teammate's email address. Select a role for them (Admin, Member or Guest) that determines what they can do in the workspace.
Your team member will get an email invite. Once they've joined your workspace, you can add them to specific projects.
To do this, go to your project's Settings page.
Find the Members section, select your teammate, and assign them a project role - this controls what they can do within just this project.
That's it!
To learn more about user management, see Manage users and roles.
",
"description_stripped": "Let's get your teammates on board!First, you'll need to invite them to your workspace before they can join specific projects:Click on your workspace name in the top-left corner, then select Settings from the dropdown.Head over to the Members tab - this is your user management hub. Click Add member on the top right.Enter your teammate's email address. Select a role for them (Admin, Member or Guest) that determines what they can do in the workspace.Your team member will get an email invite. Once they've joined your workspace, you can add them to specific projects.To do this, go to your project's Settings page.Find the Members section, select your teammate, and assign them a project role - this controls what they can do within just this project.That's it!To learn more about user management, see Manage users and roles.",
"sort_order": 3000,
"state_id": 1,
@@ -44,7 +44,7 @@
"id": 4,
"name": "3. Create and assign Work Items ✏️",
"sequence_id": 4,
- "description_html": "A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.
Ready to add something to your project's to-do list? Here's how:
Click the Add work item button in the top-right corner of the Work Items page.
Give your task a clear title and add any details in the description.
Set up the essentials:
Tip: Save time by using the keyboard shortcut C from anywhere in your project to quickly create a new work item!
Want to dive deeper into all the things you can do with work items? Check out our documentation.
",
+ "description_html": "A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.
Ready to add something to your project's to-do list? Here's how:
Click the Add work item button in the top-right corner of the Work Items page.
Give your task a clear title and add any details in the description.
Set up the essentials:
Tip: Save time by using the keyboard shortcut C from anywhere in your project to quickly create a new work item!
Want to dive deeper into all the things you can do with work items? Check out our documentation.
",
"description_stripped": "A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.Ready to add something to your project's to-do list? Here's how:Click the Add work item button in the top-right corner of the Work Items page.Give your task a clear title and add any details in the description.Set up the essentials:Assign it to a team member (or yourself!)Choose a priority levelAdd start and due dates if there's a timelineTip: Save time by using the keyboard shortcut C from anywhere in your project to quickly create a new work item!Want to dive deeper into all the things you can do with work items? Check out our documentation.",
"sort_order": 4000,
"state_id": 3,
@@ -58,7 +58,7 @@
"id": 5,
"name": "4. Visualize your work 🔮",
"sequence_id": 5,
- "description_html": "Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!
Switch between layouts
Look at the top toolbar in your project. You'll see several layout icons.
Click any of these icons to instantly switch between layouts.
Tip: Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See Layouts for more info.
Filter and display options
Need to focus on specific work?
Click the Filters dropdown in the toolbar. Select criteria and choose which items to show.
Click the Display dropdown to tailor how the information appears in your layout
Created the perfect setup? Save it for later by clicking the the Save View button.
Access saved views anytime from the Views section in your sidebar.
",
+ "description_html": "Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!
Switch between layouts
Look at the top toolbar in your project. You'll see several layout icons.
Click any of these icons to instantly switch between layouts.
Tip: Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See Layouts for more info.
Filter and display options
Need to focus on specific work?
Click the Filters dropdown in the toolbar. Select criteria and choose which items to show.
Click the Display dropdown to tailor how the information appears in your layout
Created the perfect setup? Save it for later by clicking the the Save View button.
Access saved views anytime from the Views section in your sidebar.
",
"description_stripped": "Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!Switch between layoutsLook at the top toolbar in your project. You'll see several layout icons.Click any of these icons to instantly switch between layouts.Tip: Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See Layouts for more info.Filter and display optionsNeed to focus on specific work?Click the Filters dropdown in the toolbar. Select criteria and choose which items to show.Click the Display dropdown to tailor how the information appears in your layoutCreated the perfect setup? Save it for later by clicking the the Save View button.Access saved views anytime from the Views section in your sidebar.",
"sort_order": 5000,
"state_id": 3,
@@ -72,7 +72,7 @@
"id": 6,
"name": "5. Use Cycles to time box tasks 🗓️",
"sequence_id": 6,
- "description_html": "A Cycle in Plane is like a sprint - a dedicated timeframe where your team focuses on completing specific work items. It helps you break down your project into manageable chunks with clear start and end dates so everyone knows what to work on and when it needs to be done.
Setup Cycles
Go to the Cycles section in your project (you can find it in the left sidebar)
Click the Add cycle button in the top-right corner
Enter details and set the start and end dates for your cycle.
Click Create cycle and you're ready to go!
Add existing work items to the Cycle or create new ones.
Tip: To create a new Cycle quickly, just press Q from anywhere in your project!
Want to learn more?
Starting and stopping cycles
Transferring work items between cycles
Tracking progress with charts
Check out our detailed documentation for everything you need to know!
",
+ "description_html": "A Cycle in Plane is like a sprint - a dedicated timeframe where your team focuses on completing specific work items. It helps you break down your project into manageable chunks with clear start and end dates so everyone knows what to work on and when it needs to be done.
Setup Cycles
Go to the Cycles section in your project (you can find it in the left sidebar)
Click the Add cycle button in the top-right corner
Enter details and set the start and end dates for your cycle.
Click Create cycle and you're ready to go!
Add existing work items to the Cycle or create new ones.
Tip: To create a new Cycle quickly, just press Q from anywhere in your project!
Want to learn more?
Starting and stopping cycles
Transferring work items between cycles
Tracking progress with charts
Check out our detailed documentation for everything you need to know!
",
"description_stripped": "A Cycle in Plane is like a sprint - a dedicated timeframe where your team focuses on completing specific work items. It helps you break down your project into manageable chunks with clear start and end dates so everyone knows what to work on and when it needs to be done.Setup CyclesGo to the Cycles section in your project (you can find it in the left sidebar)Click the Add cycle button in the top-right cornerEnter details and set the start and end dates for your cycle.Click Create cycle and you're ready to go!Add existing work items to the Cycle or create new ones.Tip: To create a new Cycle quickly, just press Q from anywhere in your project!Want to learn more?Starting and stopping cyclesTransferring work items between cyclesTracking progress with chartsCheck out our detailed documentation for everything you need to know!",
"sort_order": 6000,
"state_id": 1,
@@ -86,7 +86,7 @@
"id": 7,
"name": "6. Customize your settings ⚙️",
"sequence_id": 7,
- "description_html": "Now that you're getting familiar with Plane, let's explore how you can customize settings to make it work just right for you and your team!
Workspace settings
Remember those workspace settings we mentioned when inviting team members? There's a lot more you can do there:
Invite and manage workspace members
Upgrade plans and manage billing
Import data from other tools
Export your data
Manage integrations
Project Settings
Each project has its own settings where you can:
Change project details and visibility
Invite specific members to just this project
Customize your workflow States (like adding a \"Testing\" state)
Create and organize Labels
Enable or disable features you need (or don't need)
Your Profile Settings
You can also customize your own personal experience! Click on your profile icon in the top-right corner to find:
Profile settings (update your name, photo, etc.)
Choose your timezone and preferred language for the interface
Email notification preferences (what you want to be alerted about)
Appearance settings (light/dark mode)
Taking a few minutes to set things up just the way you like will make your everyday Plane experience much smoother!
Note: Some settings are only available to workspace or project admins. If you don't see certain options, you might need admin access.
",
+ "description_html": "Now that you're getting familiar with Plane, let's explore how you can customize settings to make it work just right for you and your team!
Workspace settings
Remember those workspace settings we mentioned when inviting team members? There's a lot more you can do there:
Invite and manage workspace members
Upgrade plans and manage billing
Import data from other tools
Export your data
Manage integrations
Project Settings
Each project has its own settings where you can:
Change project details and visibility
Invite specific members to just this project
Customize your workflow States (like adding a \"Testing\" state)
Create and organize Labels
Enable or disable features you need (or don't need)
Your Profile Settings
You can also customize your own personal experience! Click on your profile icon in the top-right corner to find:
Profile settings (update your name, photo, etc.)
Choose your timezone and preferred language for the interface
Email notification preferences (what you want to be alerted about)
Appearance settings (light/dark mode)
Taking a few minutes to set things up just the way you like will make your everyday Plane experience much smoother!
Note: Some settings are only available to workspace or project admins. If you don't see certain options, you might need admin access.
",
"description_stripped": "Now that you're getting familiar with Plane, let's explore how you can customize settings to make it work just right for you and your team!Workspace settingsRemember those workspace settings we mentioned when inviting team members? There's a lot more you can do there:Invite and manage workspace membersUpgrade plans and manage billingImport data from other toolsExport your dataManage integrationsProject SettingsEach project has its own settings where you can:Change project details and visibilityInvite specific members to just this projectCustomize your workflow States (like adding a \"Testing\" state)Create and organize LabelsEnable or disable features you need (or don't need)Your Profile SettingsYou can also customize your own personal experience! Click on your profile icon in the top-right corner to find:Profile settings (update your name, photo, etc.)Choose your timezone and preferred language for the interfaceEmail notification preferences (what you want to be alerted about)Appearance settings (light/dark mode)Taking a few minutes to set things up just the way you like will make your everyday Plane experience much smoother!Note: Some settings are only available to workspace or project admins. If you don't see certain options, you might need admin access.",
"sort_order": 7000,
"state_id": 1,
diff --git a/apps/api/plane/seeds/data/pages.json b/apps/api/plane/seeds/data/pages.json
index d719220bfef..00c5c91ef0a 100644
--- a/apps/api/plane/seeds/data/pages.json
+++ b/apps/api/plane/seeds/data/pages.json
@@ -1,30 +1,30 @@
[
- {
- "id": 1,
- "name": "Project Design Spec",
- "project_id": 1,
- "description_html": "Welcome to your Project Pages — the documentation hub for this specific project.
Each project in Plane can have its own Wiki space where you track plans, specs, updates, and learnings — all connected to your issues and modules.
🧭 Project Summary
Field | Details |
|---|
Project Name | ✏ Add your project name |
Owner | Add project owner(s) |
Status | 🟢 Active / 🟡 In Progress / 🔴 Blocked |
Start Date | — |
Target Release | — |
Linked Modules | Engineering, Security |
Cycle(s) | Cycle 1, Cycle 2 |
🧩 Use tables to summarize key project metadata or links.
🎯 Goals & Objectives
🎯 Primary Goals
Deliver MVP with all core features
Validate feature adoption with early users
Prepare launch plan for v1 release
⚙ Success Metrics
Metric | Target | Owner |
|---|
User adoption | 100 active users | Growth |
Performance | < 200ms latency | Backend |
Design feedback | ≥ 8/10 average rating | Design |
📈 Define measurable outcomes and track progress alongside issues.
🧩 Scope & Deliverables
Deliverable | Owner | Status |
|---|
Authentication flow | Backend | ✅ Done |
Issue board UI | Frontend | 🏗 In Progress |
API integration | Backend | ⏳ Pending |
Documentation | PM | 📝 Drafting |
🧩 Use tables or checklists to track scope and ownership.
🧱 Architecture or System Design
Use this section for technical deep dives or diagrams.
Frontend → GraphQL → Backend → PostgreSQL\nRedis for caching | RabbitMQ for background jobs
",
- "description": "{\"type\": \"doc\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Welcome to your \", \"type\": \"text\"}, {\"text\": \"Project Pages\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — the documentation hub for this specific project.\", \"type\": \"text\"}, {\"type\": \"hardBreak\"}, {\"text\": \"Each project in Plane can have its own Wiki space where you track \", \"type\": \"text\"}, {\"text\": \"plans, specs, updates, and learnings\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — all connected to your issues and modules.\", \"type\": \"text\"}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"compass\"}}, {\"text\": \" Project Summary\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Field\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Details\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Project Name\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"pencil\"}}, {\"text\": \" Add your project name\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Add project owner(s)\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Status\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"green_circle\"}}, {\"text\": \" Active / \", \"type\": \"text\"}, {\"type\": \"emoji\", \"attrs\": {\"name\": \"yellow_circle\"}}, {\"text\": \" In Progress / \", \"type\": \"text\"}, {\"type\": \"emoji\", \"attrs\": {\"name\": \"red_circle\"}}, {\"text\": \" Blocked\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Start Date\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"—\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Target Release\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"—\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Linked Modules\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Engineering, Security\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Cycle(s)\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Cycle 1, Cycle 2\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Use tables to summarize key project metadata or links.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bullseye\"}}, {\"text\": \" Goals & Objectives\", \"type\": \"text\"}]}, {\"type\": \"heading\", \"attrs\": {\"level\": 3, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bullseye\"}}, {\"text\": \" Primary Goals\", \"type\": \"text\"}]}, {\"type\": \"bulletList\", \"content\": [{\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Deliver MVP with all core features\", \"type\": \"text\"}]}]}, {\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Validate feature adoption with early users\", \"type\": \"text\"}]}]}, {\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Prepare launch plan for v1 release\", \"type\": \"text\"}]}]}]}, {\"type\": \"heading\", \"attrs\": {\"level\": 3, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"gear\"}}, {\"text\": \" Success Metrics\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Metric\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Target\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"User adoption\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"100 active users\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Growth\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Performance\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"< 200ms latency\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Design feedback\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"≥ 8/10 average rating\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Design\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"chart_increasing\"}}, {\"text\": \" Define measurable outcomes and track progress alongside issues.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Scope & Deliverables\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Deliverable\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Status\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Authentication flow\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"check_mark_button\"}}, {\"text\": \" Done\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Issue board UI\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Frontend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"building_construction\"}}, {\"text\": \" In Progress\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"API integration\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"hourglass_flowing_sand\"}}, {\"text\": \" Pending\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Documentation\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"PM\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"memo\"}}, {\"text\": \" Drafting\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Use tables or checklists to track scope and ownership.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bricks\"}}, {\"text\": \" Architecture or System Design\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Use this section for \", \"type\": \"text\"}, {\"text\": \"technical deep dives\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" or diagrams.\", \"type\": \"text\"}]}, {\"type\": \"codeBlock\", \"attrs\": {\"language\": \"bash\"}, \"content\": [{\"text\": \"Frontend → GraphQL → Backend → PostgreSQL\\nRedis for caching | RabbitMQ for background jobs\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}]}",
- "description_stripped": "Welcome to your Project Pages — the documentation hub for this specific project.Each project in Plane can have its own Wiki space where you track plans, specs, updates, and learnings — all connected to your issues and modules.🧭 Project SummaryFieldDetailsProject Name✏ Add your project nameOwnerAdd project owner(s)Status🟢 Active / 🟡 In Progress / 🔴 BlockedStart Date—Target Release—Linked ModulesEngineering, SecurityCycle(s)Cycle 1, Cycle 2🧩 Use tables to summarize key project metadata or links.🎯 Goals & Objectives🎯 Primary GoalsDeliver MVP with all core featuresValidate feature adoption with early usersPrepare launch plan for v1 release⚙ Success MetricsMetricTargetOwnerUser adoption100 active usersGrowthPerformance< 200ms latencyBackendDesign feedback≥ 8/10 average ratingDesign📈 Define measurable outcomes and track progress alongside issues.🧩 Scope & DeliverablesDeliverableOwnerStatusAuthentication flowBackend✅ DoneIssue board UIFrontend🏗 In ProgressAPI integrationBackend⏳ PendingDocumentationPM📝 Drafting🧩 Use tables or checklists to track scope and ownership.🧱 Architecture or System DesignUse this section for technical deep dives or diagrams.Frontend → GraphQL → Backend → PostgreSQL\nRedis for caching | RabbitMQ for background jobs",
- "type": "PROJECT",
- "access": 0,
- "logo_props": {
- "emoji": {
- "url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f680.png",
- "value": "128640"
- },
- "in_use": "emoji"
- }
- },
- {
- "id": 2,
- "name": "Project Draft proposal",
- "project_id": 1,
- "description": "{\"type\": \"doc\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"This is your \", \"type\": \"text\"}, {\"text\": \"Project Draft area\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"It’s visible only to you (and collaborators you explicitly share with).\", \"type\": \"text\"}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"writing_hand\"}}, {\"text\": \" Current Work in Progress\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"speech_balloon\"}}, {\"text\": \" Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet.\", \"type\": \"text\"}]}]}, {\"type\": \"taskList\", \"content\": [{\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Outline project summary and goals\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Draft new feature spec\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Review dependency list\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Collect team feedback for next iteration\", \"type\": \"text\"}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"check_mark_button\"}}, {\"text\": \" Tip: Turn these items into actionable issues when finalized.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bricks\"}}, {\"text\": \" Prototype Commands (if technical)\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"You can also use \", \"type\": \"text\"}, {\"text\": \"code blocks\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" to store snippets, scripts, or notes:\", \"type\": \"text\"}]}, {\"type\": \"codeBlock\", \"attrs\": {\"language\": \"bash\"}, \"content\": [{\"text\": \"# Rebuild Docker containers\\ndocker compose build backend frontend\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}]}",
- "description_html": "This is your Project Draft area — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.
It’s visible only to you (and collaborators you explicitly share with).
✍ Current Work in Progress
💬 Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet.
✅ Tip: Turn these items into actionable issues when finalized.
🧱 Prototype Commands (if technical)
You can also use code blocks to store snippets, scripts, or notes:
# Rebuild Docker containers\ndocker compose build backend frontend
",
- "description_stripped": "This is your Project Draft area — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.It’s visible only to you (and collaborators you explicitly share with).✍ Current Work in Progress💬 Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet. Outline project summary and goals Draft new feature spec Review dependency list Collect team feedback for next iteration✅ Tip: Turn these items into actionable issues when finalized.🧱 Prototype Commands (if technical)You can also use code blocks to store snippets, scripts, or notes:# Rebuild Docker containers\ndocker compose build backend frontend",
- "type": "PROJECT",
- "access": 1,
- "logo_props": "{\"emoji\": {\"url\": \"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f9f1.png\", \"value\": \"129521\"}, \"in_use\": \"emoji\"}"
+ {
+ "id": 1,
+ "name": "Project Design Spec",
+ "project_id": 1,
+ "description_html": "Welcome to your Project Pages — the documentation hub for this specific project.
Each project in Plane can have its own Wiki space where you track plans, specs, updates, and learnings — all connected to your issues and modules.
🧭 Project Summary
Field | Details |
|---|
Project Name | ✏ Add your project name |
Owner | Add project owner(s) |
Status | 🟢 Active / 🟡 In Progress / 🔴 Blocked |
Start Date | — |
Target Release | — |
Linked Modules | Engineering, Security |
Cycle(s) | Cycle 1, Cycle 2 |
🧩 Use tables to summarize key project metadata or links.
🎯 Goals & Objectives
🎯 Primary Goals
Deliver MVP with all core features
Validate feature adoption with early users
Prepare launch plan for v1 release
⚙ Success Metrics
Metric | Target | Owner |
|---|
User adoption | 100 active users | Growth |
Performance | < 200ms latency | Backend |
Design feedback | ≥ 8/10 average rating | Design |
📈 Define measurable outcomes and track progress alongside issues.
🧩 Scope & Deliverables
Deliverable | Owner | Status |
|---|
Authentication flow | Backend | ✅ Done |
Issue board UI | Frontend | 🏗 In Progress |
API integration | Backend | ⏳ Pending |
Documentation | PM | 📝 Drafting |
🧩 Use tables or checklists to track scope and ownership.
🧱 Architecture or System Design
Use this section for technical deep dives or diagrams.
Frontend → GraphQL → Backend → PostgreSQL\nRedis for caching | RabbitMQ for background jobs
",
+ "description": "{\"type\": \"doc\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Welcome to your \", \"type\": \"text\"}, {\"text\": \"Project Pages\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — the documentation hub for this specific project.\", \"type\": \"text\"}, {\"type\": \"hardBreak\"}, {\"text\": \"Each project in Plane can have its own Wiki space where you track \", \"type\": \"text\"}, {\"text\": \"plans, specs, updates, and learnings\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — all connected to your issues and modules.\", \"type\": \"text\"}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"compass\"}}, {\"text\": \" Project Summary\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Field\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Details\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Project Name\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"pencil\"}}, {\"text\": \" Add your project name\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Add project owner(s)\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Status\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"green_circle\"}}, {\"text\": \" Active / \", \"type\": \"text\"}, {\"type\": \"emoji\", \"attrs\": {\"name\": \"yellow_circle\"}}, {\"text\": \" In Progress / \", \"type\": \"text\"}, {\"type\": \"emoji\", \"attrs\": {\"name\": \"red_circle\"}}, {\"text\": \" Blocked\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Start Date\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"—\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Target Release\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"—\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Linked Modules\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Engineering, Security\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Cycle(s)\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Cycle 1, Cycle 2\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Use tables to summarize key project metadata or links.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bullseye\"}}, {\"text\": \" Goals & Objectives\", \"type\": \"text\"}]}, {\"type\": \"heading\", \"attrs\": {\"level\": 3, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bullseye\"}}, {\"text\": \" Primary Goals\", \"type\": \"text\"}]}, {\"type\": \"bulletList\", \"content\": [{\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Deliver MVP with all core features\", \"type\": \"text\"}]}]}, {\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Validate feature adoption with early users\", \"type\": \"text\"}]}]}, {\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Prepare launch plan for v1 release\", \"type\": \"text\"}]}]}]}, {\"type\": \"heading\", \"attrs\": {\"level\": 3, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"gear\"}}, {\"text\": \" Success Metrics\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Metric\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Target\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"User adoption\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"100 active users\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Growth\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Performance\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"< 200ms latency\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Design feedback\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"≥ 8/10 average rating\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Design\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"chart_increasing\"}}, {\"text\": \" Define measurable outcomes and track progress alongside issues.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Scope & Deliverables\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Deliverable\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Status\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Authentication flow\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"check_mark_button\"}}, {\"text\": \" Done\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Issue board UI\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Frontend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"building_construction\"}}, {\"text\": \" In Progress\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"API integration\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"hourglass_flowing_sand\"}}, {\"text\": \" Pending\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Documentation\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"PM\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"memo\"}}, {\"text\": \" Drafting\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Use tables or checklists to track scope and ownership.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bricks\"}}, {\"text\": \" Architecture or System Design\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Use this section for \", \"type\": \"text\"}, {\"text\": \"technical deep dives\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" or diagrams.\", \"type\": \"text\"}]}, {\"type\": \"codeBlock\", \"attrs\": {\"language\": \"bash\"}, \"content\": [{\"text\": \"Frontend → GraphQL → Backend → PostgreSQL\\nRedis for caching | RabbitMQ for background jobs\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}]}",
+ "description_stripped": "Welcome to your Project Pages — the documentation hub for this specific project.Each project in Plane can have its own Wiki space where you track plans, specs, updates, and learnings — all connected to your issues and modules.🧭 Project SummaryFieldDetailsProject Name✏ Add your project nameOwnerAdd project owner(s)Status🟢 Active / 🟡 In Progress / 🔴 BlockedStart Date—Target Release—Linked ModulesEngineering, SecurityCycle(s)Cycle 1, Cycle 2🧩 Use tables to summarize key project metadata or links.🎯 Goals & Objectives🎯 Primary GoalsDeliver MVP with all core featuresValidate feature adoption with early usersPrepare launch plan for v1 release⚙ Success MetricsMetricTargetOwnerUser adoption100 active usersGrowthPerformance< 200ms latencyBackendDesign feedback≥ 8/10 average ratingDesign📈 Define measurable outcomes and track progress alongside issues.🧩 Scope & DeliverablesDeliverableOwnerStatusAuthentication flowBackend✅ DoneIssue board UIFrontend🏗 In ProgressAPI integrationBackend⏳ PendingDocumentationPM📝 Drafting🧩 Use tables or checklists to track scope and ownership.🧱 Architecture or System DesignUse this section for technical deep dives or diagrams.Frontend → GraphQL → Backend → PostgreSQL\nRedis for caching | RabbitMQ for background jobs",
+ "type": "PROJECT",
+ "access": 0,
+ "logo_props": {
+ "emoji": {
+ "url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f680.png",
+ "value": "128640"
+ },
+ "in_use": "emoji"
}
-]
\ No newline at end of file
+ },
+ {
+ "id": 2,
+ "name": "Project Draft proposal",
+ "project_id": 1,
+ "description": "{\"type\": \"doc\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"This is your \", \"type\": \"text\"}, {\"text\": \"Project Draft area\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"It’s visible only to you (and collaborators you explicitly share with).\", \"type\": \"text\"}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"writing_hand\"}}, {\"text\": \" Current Work in Progress\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"speech_balloon\"}}, {\"text\": \" Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet.\", \"type\": \"text\"}]}]}, {\"type\": \"taskList\", \"content\": [{\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Outline project summary and goals\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Draft new feature spec\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Review dependency list\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Collect team feedback for next iteration\", \"type\": \"text\"}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"check_mark_button\"}}, {\"text\": \" Tip: Turn these items into actionable issues when finalized.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bricks\"}}, {\"text\": \" Prototype Commands (if technical)\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"You can also use \", \"type\": \"text\"}, {\"text\": \"code blocks\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" to store snippets, scripts, or notes:\", \"type\": \"text\"}]}, {\"type\": \"codeBlock\", \"attrs\": {\"language\": \"bash\"}, \"content\": [{\"text\": \"# Rebuild Docker containers\\ndocker compose build backend frontend\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}]}",
+ "description_html": "This is your Project Draft area — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.
It’s visible only to you (and collaborators you explicitly share with).
✍ Current Work in Progress
💬 Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet.
✅ Tip: Turn these items into actionable issues when finalized.
🧱 Prototype Commands (if technical)
You can also use code blocks to store snippets, scripts, or notes:
# Rebuild Docker containers\ndocker compose build backend frontend
",
+ "description_stripped": "This is your Project Draft area — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.It’s visible only to you (and collaborators you explicitly share with).✍ Current Work in Progress💬 Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet. Outline project summary and goals Draft new feature spec Review dependency list Collect team feedback for next iteration✅ Tip: Turn these items into actionable issues when finalized.🧱 Prototype Commands (if technical)You can also use code blocks to store snippets, scripts, or notes:# Rebuild Docker containers\ndocker compose build backend frontend",
+ "type": "PROJECT",
+ "access": 1,
+ "logo_props": "{\"emoji\": {\"url\": \"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f9f1.png\", \"value\": \"129521\"}, \"in_use\": \"emoji\"}"
+ }
+]
diff --git a/apps/api/plane/settings/__init__.py b/apps/api/plane/settings/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/settings/__init__.py
+++ b/apps/api/plane/settings/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py
index a9e9925c28c..9d651bd1b4c 100644
--- a/apps/api/plane/settings/common.py
+++ b/apps/api/plane/settings/common.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""Global Settings"""
# Python imports
@@ -36,6 +40,7 @@
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
+ "django.contrib.staticfiles",
# Inhouse apps
"plane.analytics",
"plane.app",
@@ -58,6 +63,7 @@
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
+ "whitenoise.middleware.WhiteNoiseMiddleware",
"plane.authentication.middleware.session.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
@@ -378,6 +384,7 @@
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
+ "text/markdown",
"application/rtf",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.oasis.opendocument.text",
@@ -445,6 +452,8 @@
"application/x-sql",
# Gzip
"application/x-gzip",
+ # Markdown
+ "text/markdown",
]
# Seed directory path
diff --git a/apps/api/plane/settings/local.py b/apps/api/plane/settings/local.py
index 15f05aa3d7c..dc4135bc137 100644
--- a/apps/api/plane/settings/local.py
+++ b/apps/api/plane/settings/local.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""Development settings"""
import os
@@ -76,6 +80,11 @@
"handlers": ["console"],
"propagate": False,
},
+ "plane.authentication": {
+ "level": "INFO",
+ "handlers": ["console"],
+ "propagate": False,
+ },
"plane.migrations": {
"level": "INFO",
"handlers": ["console"],
diff --git a/apps/api/plane/settings/mongo.py b/apps/api/plane/settings/mongo.py
index 879d0c436d9..7855a52d518 100644
--- a/apps/api/plane/settings/mongo.py
+++ b/apps/api/plane/settings/mongo.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.conf import settings
import logging
diff --git a/apps/api/plane/settings/openapi.py b/apps/api/plane/settings/openapi.py
index b79daeecf30..a1961a0c582 100644
--- a/apps/api/plane/settings/openapi.py
+++ b/apps/api/plane/settings/openapi.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
OpenAPI/Swagger configuration for drf-spectacular.
diff --git a/apps/api/plane/settings/production.py b/apps/api/plane/settings/production.py
index 8df7ae90601..7f3f90d6508 100644
--- a/apps/api/plane/settings/production.py
+++ b/apps/api/plane/settings/production.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""Production settings"""
import os
@@ -86,6 +90,11 @@
"handlers": ["console"],
"propagate": False,
},
+ "plane.authentication": {
+ "level": "DEBUG" if DEBUG else "INFO",
+ "handlers": ["console"],
+ "propagate": False,
+ },
"plane.migrations": {
"level": "DEBUG" if DEBUG else "INFO",
"handlers": ["console"],
diff --git a/apps/api/plane/settings/redis.py b/apps/api/plane/settings/redis.py
index 628a3d8e63b..6c7e613f04f 100644
--- a/apps/api/plane/settings/redis.py
+++ b/apps/api/plane/settings/redis.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import redis
from django.conf import settings
from urllib.parse import urlparse
diff --git a/apps/api/plane/settings/storage.py b/apps/api/plane/settings/storage.py
index 01afa62374f..e4a978bd2b1 100644
--- a/apps/api/plane/settings/storage.py
+++ b/apps/api/plane/settings/storage.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
import uuid
@@ -187,3 +191,15 @@ def upload_file(
except ClientError as e:
log_exception(e)
return False
+
+ def delete_files(self, object_names):
+ """Delete an S3 object"""
+ try:
+ self.s3_client.delete_objects(
+ Bucket=self.aws_storage_bucket_name,
+ Delete={"Objects": [{"Key": object_name} for object_name in object_names]},
+ )
+ return True
+ except ClientError as e:
+ log_exception(e)
+ return False
diff --git a/apps/api/plane/settings/test.py b/apps/api/plane/settings/test.py
index 6a75f7904d8..a8e431338b7 100644
--- a/apps/api/plane/settings/test.py
+++ b/apps/api/plane/settings/test.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""Test Settings"""
from .common import * # noqa
diff --git a/apps/api/plane/space/__init__.py b/apps/api/plane/space/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/space/__init__.py
+++ b/apps/api/plane/space/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/space/apps.py b/apps/api/plane/space/apps.py
index 6f1e76c51cb..dd178e33445 100644
--- a/apps/api/plane/space/apps.py
+++ b/apps/api/plane/space/apps.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.apps import AppConfig
diff --git a/apps/api/plane/space/serializer/__init__.py b/apps/api/plane/space/serializer/__init__.py
index a3fe1029f37..e571ac011d8 100644
--- a/apps/api/plane/space/serializer/__init__.py
+++ b/apps/api/plane/space/serializer/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from .user import UserLiteSerializer
from .issue import LabelLiteSerializer, IssuePublicSerializer
diff --git a/apps/api/plane/space/serializer/base.py b/apps/api/plane/space/serializer/base.py
index 4b92b06fc3d..9f30a7a8392 100644
--- a/apps/api/plane/space/serializer/base.py
+++ b/apps/api/plane/space/serializer/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from rest_framework import serializers
diff --git a/apps/api/plane/space/serializer/cycle.py b/apps/api/plane/space/serializer/cycle.py
index afa760a5936..617ac08428b 100644
--- a/apps/api/plane/space/serializer/cycle.py
+++ b/apps/api/plane/space/serializer/cycle.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Module imports
from .base import BaseSerializer
from plane.db.models import Cycle
diff --git a/apps/api/plane/space/serializer/intake.py b/apps/api/plane/space/serializer/intake.py
index 444c20d429f..cf22cebbb1e 100644
--- a/apps/api/plane/space/serializer/intake.py
+++ b/apps/api/plane/space/serializer/intake.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third Party imports
from rest_framework import serializers
diff --git a/apps/api/plane/space/serializer/issue.py b/apps/api/plane/space/serializer/issue.py
index a89846cfc78..51dd1f41d15 100644
--- a/apps/api/plane/space/serializer/issue.py
+++ b/apps/api/plane/space/serializer/issue.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.utils import timezone
@@ -193,7 +197,7 @@ class Meta:
fields = [
"id",
"name",
- "description",
+ "description_json",
"description_html",
"priority",
"start_date",
diff --git a/apps/api/plane/space/serializer/module.py b/apps/api/plane/space/serializer/module.py
index 53840f0782a..81ba93c1365 100644
--- a/apps/api/plane/space/serializer/module.py
+++ b/apps/api/plane/space/serializer/module.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Module imports
from .base import BaseSerializer
from plane.db.models import Module
diff --git a/apps/api/plane/space/serializer/project.py b/apps/api/plane/space/serializer/project.py
index f79eef686dd..62be19f4f4b 100644
--- a/apps/api/plane/space/serializer/project.py
+++ b/apps/api/plane/space/serializer/project.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Module imports
from .base import BaseSerializer
from plane.db.models import Project
diff --git a/apps/api/plane/space/serializer/state.py b/apps/api/plane/space/serializer/state.py
index 184f48b4077..410b408f0b3 100644
--- a/apps/api/plane/space/serializer/state.py
+++ b/apps/api/plane/space/serializer/state.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Module imports
from .base import BaseSerializer
from plane.db.models import State
diff --git a/apps/api/plane/space/serializer/user.py b/apps/api/plane/space/serializer/user.py
index 9b707a3434e..4ecbad80e94 100644
--- a/apps/api/plane/space/serializer/user.py
+++ b/apps/api/plane/space/serializer/user.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Module imports
from .base import BaseSerializer
from plane.db.models import User
diff --git a/apps/api/plane/space/serializer/workspace.py b/apps/api/plane/space/serializer/workspace.py
index 4945af96afe..c63dfe2a5a6 100644
--- a/apps/api/plane/space/serializer/workspace.py
+++ b/apps/api/plane/space/serializer/workspace.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Module imports
from .base import BaseSerializer
from plane.db.models import Workspace
diff --git a/apps/api/plane/space/urls/__init__.py b/apps/api/plane/space/urls/__init__.py
index d9a1f6ec330..06d3a117a1a 100644
--- a/apps/api/plane/space/urls/__init__.py
+++ b/apps/api/plane/space/urls/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from .intake import urlpatterns as intake_urls
from .issue import urlpatterns as issue_urls
from .project import urlpatterns as project_urls
diff --git a/apps/api/plane/space/urls/asset.py b/apps/api/plane/space/urls/asset.py
index 2a5c30a2212..050aeb4abc5 100644
--- a/apps/api/plane/space/urls/asset.py
+++ b/apps/api/plane/space/urls/asset.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.urls import path
diff --git a/apps/api/plane/space/urls/intake.py b/apps/api/plane/space/urls/intake.py
index 59fda12e291..470f7f7b7d0 100644
--- a/apps/api/plane/space/urls/intake.py
+++ b/apps/api/plane/space/urls/intake.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
diff --git a/apps/api/plane/space/urls/issue.py b/apps/api/plane/space/urls/issue.py
index bb63e669539..5ea7671c251 100644
--- a/apps/api/plane/space/urls/issue.py
+++ b/apps/api/plane/space/urls/issue.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
diff --git a/apps/api/plane/space/urls/project.py b/apps/api/plane/space/urls/project.py
index 068b8c5c17f..1d58aba421b 100644
--- a/apps/api/plane/space/urls/project.py
+++ b/apps/api/plane/space/urls/project.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
diff --git a/apps/api/plane/space/utils/grouper.py b/apps/api/plane/space/utils/grouper.py
index f8e2c50a446..e5f893bd5b7 100644
--- a/apps/api/plane/space/utils/grouper.py
+++ b/apps/api/plane/space/utils/grouper.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
diff --git a/apps/api/plane/space/views/__init__.py b/apps/api/plane/space/views/__init__.py
index 22acfd15bd2..f70d094debb 100644
--- a/apps/api/plane/space/views/__init__.py
+++ b/apps/api/plane/space/views/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from .project import (
ProjectDeployBoardPublicSettingsEndpoint,
WorkspaceProjectDeployBoardEndpoint,
diff --git a/apps/api/plane/space/views/asset.py b/apps/api/plane/space/views/asset.py
index faabd97ab6f..1749a8fd462 100644
--- a/apps/api/plane/space/views/asset.py
+++ b/apps/api/plane/space/views/asset.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import uuid
diff --git a/apps/api/plane/space/views/base.py b/apps/api/plane/space/views/base.py
index 9be6a2e107d..cf8cdbdc5c9 100644
--- a/apps/api/plane/space/views/base.py
+++ b/apps/api/plane/space/views/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import zoneinfo
from django.conf import settings
diff --git a/apps/api/plane/space/views/cycle.py b/apps/api/plane/space/views/cycle.py
index 505c17ba406..72bec30641d 100644
--- a/apps/api/plane/space/views/cycle.py
+++ b/apps/api/plane/space/views/cycle.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third Party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
diff --git a/apps/api/plane/space/views/intake.py b/apps/api/plane/space/views/intake.py
index 7ea2dee91fb..4d9913193ce 100644
--- a/apps/api/plane/space/views/intake.py
+++ b/apps/api/plane/space/views/intake.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
@@ -140,7 +144,7 @@ def create(self, request, anchor, intake_id):
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
- description=request.data.get("issue", {}).get("description", {}),
+ description_json=request.data.get("issue", {}).get("description_json", {}),
description_html=request.data.get("issue", {}).get("description_html", ""),
priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_deploy_board.project_id,
@@ -201,7 +205,7 @@ def partial_update(self, request, anchor, intake_id, pk):
issue_data = {
"name": issue_data.get("name", issue.name),
"description_html": issue_data.get("description_html", issue.description_html),
- "description": issue_data.get("description", issue.description),
+ "description_json": issue_data.get("description_json", issue.description_json),
}
issue_serializer = IssueCreateSerializer(
diff --git a/apps/api/plane/space/views/issue.py b/apps/api/plane/space/views/issue.py
index 220fc130734..9e2187466aa 100644
--- a/apps/api/plane/space/views/issue.py
+++ b/apps/api/plane/space/views/issue.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
@@ -744,7 +748,7 @@ def get(self, request, anchor, issue_id):
"name",
"state_id",
"sort_order",
- "description",
+ "description_json",
"description_html",
"description_stripped",
"description_binary",
diff --git a/apps/api/plane/space/views/label.py b/apps/api/plane/space/views/label.py
index 51ddb832e41..f7cde57eb33 100644
--- a/apps/api/plane/space/views/label.py
+++ b/apps/api/plane/space/views/label.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
diff --git a/apps/api/plane/space/views/meta.py b/apps/api/plane/space/views/meta.py
index be612db700b..740bed19f3c 100644
--- a/apps/api/plane/space/views/meta.py
+++ b/apps/api/plane/space/views/meta.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# third party
from rest_framework.permissions import AllowAny
from rest_framework import status
diff --git a/apps/api/plane/space/views/module.py b/apps/api/plane/space/views/module.py
index 7c4628f64ff..2df0166acaf 100644
--- a/apps/api/plane/space/views/module.py
+++ b/apps/api/plane/space/views/module.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third Party imports
from rest_framework import status
from rest_framework.permissions import AllowAny
diff --git a/apps/api/plane/space/views/project.py b/apps/api/plane/space/views/project.py
index 0e19085a03d..168c42624f1 100644
--- a/apps/api/plane/space/views/project.py
+++ b/apps/api/plane/space/views/project.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.db.models import Exists, OuterRef
diff --git a/apps/api/plane/space/views/state.py b/apps/api/plane/space/views/state.py
index c1318660046..05b791475c5 100644
--- a/apps/api/plane/space/views/state.py
+++ b/apps/api/plane/space/views/state.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.db.models import Q
diff --git a/apps/api/plane/static/logos/Logo.png b/apps/api/plane/static/logos/Logo.png
new file mode 100644
index 00000000000..385ed57aac8
Binary files /dev/null and b/apps/api/plane/static/logos/Logo.png differ
diff --git a/apps/api/plane/static/logos/github_32px.png b/apps/api/plane/static/logos/github_32px.png
new file mode 100644
index 00000000000..4a9e5ab8ce3
Binary files /dev/null and b/apps/api/plane/static/logos/github_32px.png differ
diff --git a/apps/api/plane/static/logos/linkedin_32px.png b/apps/api/plane/static/logos/linkedin_32px.png
new file mode 100644
index 00000000000..396e9327dce
Binary files /dev/null and b/apps/api/plane/static/logos/linkedin_32px.png differ
diff --git a/apps/api/plane/static/logos/twitter_32px.png b/apps/api/plane/static/logos/twitter_32px.png
new file mode 100644
index 00000000000..537562ea710
Binary files /dev/null and b/apps/api/plane/static/logos/twitter_32px.png differ
diff --git a/apps/api/plane/static/logos/website_32px.png b/apps/api/plane/static/logos/website_32px.png
new file mode 100644
index 00000000000..970a13f1c6e
Binary files /dev/null and b/apps/api/plane/static/logos/website_32px.png differ
diff --git a/apps/api/plane/tests/__init__.py b/apps/api/plane/tests/__init__.py
index 73d90cd21ba..5f9223043a6 100644
--- a/apps/api/plane/tests/__init__.py
+++ b/apps/api/plane/tests/__init__.py
@@ -1 +1,5 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Test package initialization
diff --git a/apps/api/plane/tests/apps.py b/apps/api/plane/tests/apps.py
index 577414e63a2..96698696966 100644
--- a/apps/api/plane/tests/apps.py
+++ b/apps/api/plane/tests/apps.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.apps import AppConfig
diff --git a/apps/api/plane/tests/conftest.py b/apps/api/plane/tests/conftest.py
index abfede197c9..870779c42d6 100644
--- a/apps/api/plane/tests/conftest.py
+++ b/apps/api/plane/tests/conftest.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import pytest
from rest_framework.test import APIClient
from pytest_django.fixtures import django_db_setup
diff --git a/apps/api/plane/tests/conftest_external.py b/apps/api/plane/tests/conftest_external.py
index cebb768ca53..cd5469caa65 100644
--- a/apps/api/plane/tests/conftest_external.py
+++ b/apps/api/plane/tests/conftest_external.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import pytest
from unittest.mock import MagicMock, patch
diff --git a/apps/api/plane/tests/contract/__init__.py b/apps/api/plane/tests/contract/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/tests/contract/__init__.py
+++ b/apps/api/plane/tests/contract/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/tests/contract/api/__init__.py b/apps/api/plane/tests/contract/api/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/tests/contract/api/__init__.py
+++ b/apps/api/plane/tests/contract/api/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/tests/contract/api/test_cycles.py b/apps/api/plane/tests/contract/api/test_cycles.py
index 644fe2bef9b..d0138de8b8e 100644
--- a/apps/api/plane/tests/contract/api/test_cycles.py
+++ b/apps/api/plane/tests/contract/api/test_cycles.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import pytest
from rest_framework import status
from django.utils import timezone
diff --git a/apps/api/plane/tests/contract/api/test_labels.py b/apps/api/plane/tests/contract/api/test_labels.py
index a3a43d90aae..db5340dfdf3 100644
--- a/apps/api/plane/tests/contract/api/test_labels.py
+++ b/apps/api/plane/tests/contract/api/test_labels.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import pytest
from rest_framework import status
from uuid import uuid4
diff --git a/apps/api/plane/tests/contract/app/__init__.py b/apps/api/plane/tests/contract/app/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/tests/contract/app/__init__.py
+++ b/apps/api/plane/tests/contract/app/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/tests/contract/app/test_api_token.py b/apps/api/plane/tests/contract/app/test_api_token.py
index 24fac7bb42e..ed071b98cb0 100644
--- a/apps/api/plane/tests/contract/app/test_api_token.py
+++ b/apps/api/plane/tests/contract/app/test_api_token.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import pytest
from datetime import timedelta
from uuid import uuid4
diff --git a/apps/api/plane/tests/contract/app/test_authentication.py b/apps/api/plane/tests/contract/app/test_authentication.py
index 1c044f19283..808416b028e 100644
--- a/apps/api/plane/tests/contract/app/test_authentication.py
+++ b/apps/api/plane/tests/contract/app/test_authentication.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import json
import uuid
import pytest
diff --git a/apps/api/plane/tests/contract/app/test_project_app.py b/apps/api/plane/tests/contract/app/test_project_app.py
index 38b0f51f3b5..979c5e805c4 100644
--- a/apps/api/plane/tests/contract/app/test_project_app.py
+++ b/apps/api/plane/tests/contract/app/test_project_app.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import pytest
from rest_framework import status
import uuid
@@ -6,7 +10,7 @@
from plane.db.models import (
Project,
ProjectMember,
- IssueUserProperty,
+ ProjectUserProperty,
State,
WorkspaceMember,
User,
@@ -82,8 +86,8 @@ def test_create_project_valid_data(self, session_client, workspace, create_user)
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 +120,8 @@ def test_create_project_with_project_lead(self, session_client, workspace, creat
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):
diff --git a/apps/api/plane/tests/contract/app/test_workspace_app.py b/apps/api/plane/tests/contract/app/test_workspace_app.py
index 47b0497952a..427bad60b64 100644
--- a/apps/api/plane/tests/contract/app/test_workspace_app.py
+++ b/apps/api/plane/tests/contract/app/test_workspace_app.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import pytest
from django.urls import reverse
from rest_framework import status
diff --git a/apps/api/plane/tests/factories.py b/apps/api/plane/tests/factories.py
index b8cd78361ab..4d39d832fae 100644
--- a/apps/api/plane/tests/factories.py
+++ b/apps/api/plane/tests/factories.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import factory
from uuid import uuid4
from django.utils import timezone
diff --git a/apps/api/plane/tests/smoke/__init__.py b/apps/api/plane/tests/smoke/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/tests/smoke/__init__.py
+++ b/apps/api/plane/tests/smoke/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/tests/smoke/test_auth_smoke.py b/apps/api/plane/tests/smoke/test_auth_smoke.py
index c5a671e9af9..1537db79f7e 100644
--- a/apps/api/plane/tests/smoke/test_auth_smoke.py
+++ b/apps/api/plane/tests/smoke/test_auth_smoke.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import pytest
import requests
from django.urls import reverse
diff --git a/apps/api/plane/tests/unit/__init__.py b/apps/api/plane/tests/unit/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/tests/unit/__init__.py
+++ b/apps/api/plane/tests/unit/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py b/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py
index 9886036599a..c153703baac 100644
--- a/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py
+++ b/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import pytest
from plane.db.models import Project, ProjectMember, Issue, FileAsset
from unittest.mock import patch, MagicMock
diff --git a/apps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.py b/apps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.py
new file mode 100644
index 00000000000..2838260e890
--- /dev/null
+++ b/apps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.py
@@ -0,0 +1,126 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
+import pytest
+from unittest.mock import patch, MagicMock
+from plane.bgtasks.work_item_link_task import safe_get, validate_url_ip
+
+
+def _make_response(status_code=200, headers=None, is_redirect=False, content=b""):
+ """Create a mock requests.Response."""
+ resp = MagicMock()
+ resp.status_code = status_code
+ resp.is_redirect = is_redirect
+ resp.headers = headers or {}
+ resp.content = content
+ return resp
+
+
+@pytest.mark.unit
+class TestValidateUrlIp:
+ """Test validate_url_ip blocks private/internal IPs."""
+
+ def test_rejects_private_ip(self):
+ with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
+ mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
+ with pytest.raises(ValueError, match="private/internal"):
+ validate_url_ip("http://example.com")
+
+ def test_rejects_loopback(self):
+ with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
+ mock_dns.return_value = [(None, None, None, None, ("127.0.0.1", 0))]
+ with pytest.raises(ValueError, match="private/internal"):
+ validate_url_ip("http://example.com")
+
+ def test_rejects_non_http_scheme(self):
+ with pytest.raises(ValueError, match="Only HTTP and HTTPS"):
+ validate_url_ip("file:///etc/passwd")
+
+ def test_allows_public_ip(self):
+ with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
+ mock_dns.return_value = [(None, None, None, None, ("93.184.216.34", 0))]
+ validate_url_ip("https://example.com") # Should not raise
+
+
+@pytest.mark.unit
+class TestSafeGet:
+ """Test safe_get follows redirects safely and blocks SSRF."""
+
+ @patch("plane.bgtasks.work_item_link_task.requests.get")
+ @patch("plane.bgtasks.work_item_link_task.validate_url_ip")
+ def test_returns_response_for_non_redirect(self, mock_validate, mock_get):
+ final_resp = _make_response(status_code=200, content=b"OK")
+ mock_get.return_value = final_resp
+
+ response, final_url = safe_get("https://example.com")
+
+ assert response is final_resp
+ assert final_url == "https://example.com"
+ mock_validate.assert_called_once_with("https://example.com")
+
+ @patch("plane.bgtasks.work_item_link_task.requests.get")
+ @patch("plane.bgtasks.work_item_link_task.validate_url_ip")
+ def test_follows_redirect_and_validates_each_hop(self, mock_validate, mock_get):
+ redirect_resp = _make_response(
+ status_code=301,
+ is_redirect=True,
+ headers={"Location": "https://other.com/page"},
+ )
+ final_resp = _make_response(status_code=200, content=b"OK")
+ mock_get.side_effect = [redirect_resp, final_resp]
+
+ response, final_url = safe_get("https://example.com")
+
+ assert response is final_resp
+ assert final_url == "https://other.com/page"
+ # Should validate both the initial URL and the redirect target
+ assert mock_validate.call_count == 2
+ mock_validate.assert_any_call("https://example.com")
+ mock_validate.assert_any_call("https://other.com/page")
+
+ @patch("plane.bgtasks.work_item_link_task.requests.get")
+ @patch("plane.bgtasks.work_item_link_task.validate_url_ip")
+ def test_blocks_redirect_to_private_ip(self, mock_validate, mock_get):
+ redirect_resp = _make_response(
+ status_code=302,
+ is_redirect=True,
+ headers={"Location": "http://192.168.1.1:8080"},
+ )
+ mock_get.return_value = redirect_resp
+ # First call (initial URL) succeeds, second call (redirect target) fails
+ mock_validate.side_effect = [None, ValueError("Access to private/internal networks is not allowed")]
+
+ with pytest.raises(ValueError, match="private/internal"):
+ safe_get("https://evil.com/redirect")
+
+ @patch("plane.bgtasks.work_item_link_task.requests.get")
+ @patch("plane.bgtasks.work_item_link_task.validate_url_ip")
+ def test_raises_on_too_many_redirects(self, mock_validate, mock_get):
+ redirect_resp = _make_response(
+ status_code=302,
+ is_redirect=True,
+ headers={"Location": "https://example.com/loop"},
+ )
+ mock_get.return_value = redirect_resp
+
+ with pytest.raises(RuntimeError, match="Too many redirects"):
+ safe_get("https://example.com/start")
+
+ @patch("plane.bgtasks.work_item_link_task.requests.get")
+ @patch("plane.bgtasks.work_item_link_task.validate_url_ip")
+ def test_succeeds_at_exact_max_redirects(self, mock_validate, mock_get):
+ """After exactly MAX_REDIRECTS hops, if the final response is 200, it should succeed."""
+ redirect_resp = _make_response(
+ status_code=302,
+ is_redirect=True,
+ headers={"Location": "https://example.com/next"},
+ )
+ final_resp = _make_response(status_code=200, content=b"OK")
+ # 5 redirects then a 200
+ mock_get.side_effect = [redirect_resp] * 5 + [final_resp]
+
+ response, final_url = safe_get("https://example.com/start")
+
+ assert response is final_resp
+ assert not response.is_redirect
diff --git a/apps/api/plane/tests/unit/middleware/__init__.py b/apps/api/plane/tests/unit/middleware/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/tests/unit/middleware/__init__.py
+++ b/apps/api/plane/tests/unit/middleware/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/tests/unit/middleware/test_db_routing.py b/apps/api/plane/tests/unit/middleware/test_db_routing.py
index 5ac71696ac2..9f5439e75cb 100644
--- a/apps/api/plane/tests/unit/middleware/test_db_routing.py
+++ b/apps/api/plane/tests/unit/middleware/test_db_routing.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
Unit tests for ReadReplicaRoutingMiddleware.
This module contains comprehensive tests for the ReadReplicaRoutingMiddleware
diff --git a/apps/api/plane/tests/unit/models/__init__.py b/apps/api/plane/tests/unit/models/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/tests/unit/models/__init__.py
+++ b/apps/api/plane/tests/unit/models/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/tests/unit/models/test_issue_comment_modal.py b/apps/api/plane/tests/unit/models/test_issue_comment_modal.py
index 98a0b05b24d..37f743d7640 100644
--- a/apps/api/plane/tests/unit/models/test_issue_comment_modal.py
+++ b/apps/api/plane/tests/unit/models/test_issue_comment_modal.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import pytest
from plane.db.models import IssueComment, Description, Project, Issue, Workspace, State
diff --git a/apps/api/plane/tests/unit/models/test_workspace_model.py b/apps/api/plane/tests/unit/models/test_workspace_model.py
index 26a79751268..405538cfbe2 100644
--- a/apps/api/plane/tests/unit/models/test_workspace_model.py
+++ b/apps/api/plane/tests/unit/models/test_workspace_model.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import pytest
from uuid import uuid4
diff --git a/apps/api/plane/tests/unit/serializers/__init__.py b/apps/api/plane/tests/unit/serializers/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/tests/unit/serializers/__init__.py
+++ b/apps/api/plane/tests/unit/serializers/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py b/apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py
index eac92384b37..59a909eeb7a 100644
--- a/apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py
+++ b/apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import pytest
from plane.db.models import (
diff --git a/apps/api/plane/tests/unit/serializers/test_label.py b/apps/api/plane/tests/unit/serializers/test_label.py
index 91cde1c4ad8..a4ebc887522 100644
--- a/apps/api/plane/tests/unit/serializers/test_label.py
+++ b/apps/api/plane/tests/unit/serializers/test_label.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import pytest
from plane.app.serializers import LabelSerializer
from plane.db.models import Project, Label
@@ -10,9 +14,7 @@ class TestLabelSerializer:
@pytest.mark.django_db
def test_label_serializer_create_valid_data(self, db, workspace):
"""Test creating a label with valid data"""
- project = Project.objects.create(
- name="Test Project", identifier="TEST", workspace=workspace
- )
+ project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace)
serializer = LabelSerializer(
data={"name": "Test Label"},
@@ -30,14 +32,10 @@ def test_label_serializer_create_valid_data(self, db, workspace):
@pytest.mark.django_db
def test_label_serializer_create_duplicate_name(self, db, workspace):
"""Test creating a label with a duplicate name"""
- project = Project.objects.create(
- name="Test Project", identifier="TEST", workspace=workspace
- )
+ project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace)
Label.objects.create(name="Test Label", project=project)
- serializer = LabelSerializer(
- data={"name": "Test Label"}, context={"project_id": project.id}
- )
+ serializer = LabelSerializer(data={"name": "Test Label"}, context={"project_id": project.id})
assert not serializer.is_valid()
assert serializer.errors == {"name": ["LABEL_NAME_ALREADY_EXISTS"]}
diff --git a/apps/api/plane/tests/unit/serializers/test_workspace.py b/apps/api/plane/tests/unit/serializers/test_workspace.py
index 21844c714b8..f59667f701b 100644
--- a/apps/api/plane/tests/unit/serializers/test_workspace.py
+++ b/apps/api/plane/tests/unit/serializers/test_workspace.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import pytest
from uuid import uuid4
diff --git a/apps/api/plane/tests/unit/settings/__init__.py b/apps/api/plane/tests/unit/settings/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/tests/unit/settings/__init__.py
+++ b/apps/api/plane/tests/unit/settings/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/tests/unit/settings/test_storage.py b/apps/api/plane/tests/unit/settings/test_storage.py
index fe8cf43f8b1..00856aeecb6 100644
--- a/apps/api/plane/tests/unit/settings/test_storage.py
+++ b/apps/api/plane/tests/unit/settings/test_storage.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import os
from unittest.mock import Mock, patch
import pytest
diff --git a/apps/api/plane/tests/unit/utils/__init__.py b/apps/api/plane/tests/unit/utils/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/tests/unit/utils/__init__.py
+++ b/apps/api/plane/tests/unit/utils/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/tests/unit/utils/test_url.py b/apps/api/plane/tests/unit/utils/test_url.py
index 465cb3023b1..82b5b106d01 100644
--- a/apps/api/plane/tests/unit/utils/test_url.py
+++ b/apps/api/plane/tests/unit/utils/test_url.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import pytest
from plane.utils.url import (
contains_url,
diff --git a/apps/api/plane/tests/unit/utils/test_uuid.py b/apps/api/plane/tests/unit/utils/test_uuid.py
index d47e59c4b79..33ddebb921d 100644
--- a/apps/api/plane/tests/unit/utils/test_uuid.py
+++ b/apps/api/plane/tests/unit/utils/test_uuid.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import uuid
import pytest
from plane.utils.uuid import is_valid_uuid, convert_uuid_to_integer
diff --git a/apps/api/plane/throttles/asset.py b/apps/api/plane/throttles/asset.py
index 48465004938..bdc3be799f2 100644
--- a/apps/api/plane/throttles/asset.py
+++ b/apps/api/plane/throttles/asset.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from rest_framework.throttling import SimpleRateThrottle
diff --git a/apps/api/plane/urls.py b/apps/api/plane/urls.py
index 4b1062559a6..f5e43408cb8 100644
--- a/apps/api/plane/urls.py
+++ b/apps/api/plane/urls.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""plane URL Configuration"""
from django.conf import settings
diff --git a/apps/api/plane/utils/__init__.py b/apps/api/plane/utils/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/utils/__init__.py
+++ b/apps/api/plane/utils/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/utils/analytics_events.py b/apps/api/plane/utils/analytics_events.py
new file mode 100644
index 00000000000..ce06ba92e68
--- /dev/null
+++ b/apps/api/plane/utils/analytics_events.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
+USER_JOINED_WORKSPACE = "user_joined_workspace"
+USER_INVITED_TO_WORKSPACE = "user_invited_to_workspace"
+WORKSPACE_CREATED = "workspace_created"
+WORKSPACE_DELETED = "workspace_deleted"
diff --git a/apps/api/plane/utils/analytics_plot.py b/apps/api/plane/utils/analytics_plot.py
index 12fa39cc030..acd86aca868 100644
--- a/apps/api/plane/utils/analytics_plot.py
+++ b/apps/api/plane/utils/analytics_plot.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
from datetime import timedelta
from itertools import groupby
diff --git a/apps/api/plane/utils/build_chart.py b/apps/api/plane/utils/build_chart.py
index 9a2d9c3a0c3..bf4d1cf2b61 100644
--- a/apps/api/plane/utils/build_chart.py
+++ b/apps/api/plane/utils/build_chart.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from typing import Dict, Any, Tuple, Optional, List, Union
@@ -51,7 +55,7 @@ def get_x_axis_field() -> Dict[str, Tuple[str, str, Optional[Dict[str, Any]]]]:
"assignees__display_name",
{"issue_assignee__deleted_at__isnull": True},
),
- "ESTIMATE_POINTS": ("estimate_point__value", "estimate_point__key", None),
+ "ESTIMATE_POINTS": ("estimate_point__key", "estimate_point__value", None),
"CYCLES": (
"issue_cycle__cycle_id",
"issue_cycle__cycle__name",
diff --git a/apps/api/plane/utils/cache.py b/apps/api/plane/utils/cache.py
index da3fd45177d..9ff5db6d908 100644
--- a/apps/api/plane/utils/cache.py
+++ b/apps/api/plane/utils/cache.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
from functools import wraps
diff --git a/apps/api/plane/utils/color.py b/apps/api/plane/utils/color.py
index 8c45389bdf8..61a572dc00a 100644
--- a/apps/api/plane/utils/color.py
+++ b/apps/api/plane/utils/color.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import random
import string
diff --git a/apps/api/plane/utils/constants.py b/apps/api/plane/utils/constants.py
index 0d5e64a20b9..1ccc501ddcb 100644
--- a/apps/api/plane/utils/constants.py
+++ b/apps/api/plane/utils/constants.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
RESTRICTED_WORKSPACE_SLUGS = [
"404",
"accounts",
diff --git a/apps/api/plane/utils/content_validator.py b/apps/api/plane/utils/content_validator.py
index 10e83b85dab..1b4ede2626f 100644
--- a/apps/api/plane/utils/content_validator.py
+++ b/apps/api/plane/utils/content_validator.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import base64
import nh3
@@ -56,9 +60,7 @@ def validate_binary_data(data):
# Check for suspicious text patterns (HTML/JS)
try:
decoded_text = binary_data.decode("utf-8", errors="ignore")[:200]
- if any(
- pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS
- ):
+ if any(pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS):
return False, "Binary data contains suspicious content patterns"
except Exception:
pass # Binary data might not be decodable as text, which is fine
@@ -137,8 +139,6 @@ def validate_binary_data(data):
"rowspan",
"colwidth",
"background",
- "hideContent",
- "hidecontent",
"style",
},
"td": {
@@ -148,8 +148,6 @@ def validate_binary_data(data):
"background",
"textColor",
"textcolor",
- "hideContent",
- "hidecontent",
"style",
},
"tr": {"background", "textColor", "textcolor", "style"},
diff --git a/apps/api/plane/utils/core/__init__.py b/apps/api/plane/utils/core/__init__.py
index 37c6e3741e7..7f119b62f9a 100644
--- a/apps/api/plane/utils/core/__init__.py
+++ b/apps/api/plane/utils/core/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
Core utilities for Plane database routing and request scoping.
This package contains essential components for managing read replica routing
diff --git a/apps/api/plane/utils/core/dbrouters.py b/apps/api/plane/utils/core/dbrouters.py
index e175683319c..fdd00cca2c6 100644
--- a/apps/api/plane/utils/core/dbrouters.py
+++ b/apps/api/plane/utils/core/dbrouters.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
Database router for read replica selection.
This router determines which database to use for read/write operations
diff --git a/apps/api/plane/utils/core/mixins/__init__.py b/apps/api/plane/utils/core/mixins/__init__.py
index cedd9d45519..73fe2ccc98d 100644
--- a/apps/api/plane/utils/core/mixins/__init__.py
+++ b/apps/api/plane/utils/core/mixins/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
Core mixins for read replica functionality.
This package provides mixins for different aspects of read replica management
diff --git a/apps/api/plane/utils/core/mixins/view.py b/apps/api/plane/utils/core/mixins/view.py
index e15ec6771d1..4d923e1c133 100644
--- a/apps/api/plane/utils/core/mixins/view.py
+++ b/apps/api/plane/utils/core/mixins/view.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
Mixins for Django REST Framework views.
"""
diff --git a/apps/api/plane/utils/core/request_scope.py b/apps/api/plane/utils/core/request_scope.py
index b09e77101f4..b8b137120ea 100644
--- a/apps/api/plane/utils/core/request_scope.py
+++ b/apps/api/plane/utils/core/request_scope.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
Database routing utilities for read replica selection.
This module provides request-scoped context management for database routing,
diff --git a/apps/api/plane/utils/csv_utils.py b/apps/api/plane/utils/csv_utils.py
new file mode 100644
index 00000000000..26c6e893752
--- /dev/null
+++ b/apps/api/plane/utils/csv_utils.py
@@ -0,0 +1,26 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
+# CSV utility functions for safe export
+# Characters that trigger formula evaluation in spreadsheet applications
+_CSV_FORMULA_TRIGGERS = frozenset(("=", "+", "-", "@", "\t", "\r", "\n"))
+
+
+def sanitize_csv_value(value):
+ """Sanitize a value for CSV export to prevent formula injection.
+
+ Prefixes string values starting with formula-triggering characters
+ with a single quote so spreadsheet applications treat them as text
+ instead of evaluating them as formulas.
+
+ See: https://owasp.org/www-community/attacks/CSV_Injection
+ """
+ if isinstance(value, str) and value and value[0] in _CSV_FORMULA_TRIGGERS:
+ return "'" + value
+ return value
+
+
+def sanitize_csv_row(row):
+ """Sanitize all values in a CSV row."""
+ return [sanitize_csv_value(v) for v in row]
diff --git a/apps/api/plane/utils/cycle_transfer_issues.py b/apps/api/plane/utils/cycle_transfer_issues.py
index ec934e8892d..79634013822 100644
--- a/apps/api/plane/utils/cycle_transfer_issues.py
+++ b/apps/api/plane/utils/cycle_transfer_issues.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
@@ -51,9 +55,7 @@ def transfer_cycle_issues(
dict: Response data with success or error message
"""
# Get the new cycle
- new_cycle = Cycle.objects.filter(
- workspace__slug=slug, project_id=project_id, pk=new_cycle_id
- ).first()
+ new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first()
# Check if new cycle is already completed
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
@@ -216,9 +218,7 @@ def transfer_cycle_issues(
assignee_estimate_distribution = [
{
"display_name": item["display_name"],
- "assignee_id": (
- str(item["assignee_id"]) if item["assignee_id"] else None
- ),
+ "assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"avatar_url": item.get("avatar_url"),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
@@ -310,9 +310,7 @@ def transfer_cycle_issues(
)
)
.values("display_name", "assignee_id", "avatar_url")
- .annotate(
- total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
- )
+ .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"id",
@@ -360,9 +358,7 @@ def transfer_cycle_issues(
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
- .annotate(
- total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
- )
+ .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"id",
@@ -409,9 +405,7 @@ def transfer_cycle_issues(
)
# Get the current cycle and save progress snapshot
- current_cycle = Cycle.objects.filter(
- workspace__slug=slug, project_id=project_id, pk=cycle_id
- ).first()
+ current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first()
current_cycle.progress_snapshot = {
"total_issues": old_cycle.total_issues,
@@ -461,9 +455,7 @@ def transfer_cycle_issues(
)
# Bulk update cycle issues
- cycle_issues = CycleIssue.objects.bulk_update(
- updated_cycles, ["cycle_id"], batch_size=100
- )
+ cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
# Capture Issue Activity
issue_activity.delay(
diff --git a/apps/api/plane/utils/date_utils.py b/apps/api/plane/utils/date_utils.py
index f15e7f119bd..d25d5b1eca4 100644
--- a/apps/api/plane/utils/date_utils.py
+++ b/apps/api/plane/utils/date_utils.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from datetime import datetime, timedelta, date
from django.utils import timezone
from typing import Dict, Optional, List, Union, Tuple, Any
diff --git a/apps/api/plane/utils/email.py b/apps/api/plane/utils/email.py
new file mode 100644
index 00000000000..f950e94515c
--- /dev/null
+++ b/apps/api/plane/utils/email.py
@@ -0,0 +1,42 @@
+# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
+# SPDX-License-Identifier: LicenseRef-Plane-Commercial
+#
+# Licensed under the Plane Commercial License (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# https://plane.so/legals/eula
+#
+# DO NOT remove or modify this notice.
+# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
+
+# Python imports
+import re
+
+# Django imports
+from django.utils.html import strip_tags
+
+
+def generate_plain_text_from_html(html_content):
+ """
+ Generate clean plain text from HTML email template.
+ Removes all HTML tags, CSS styles, and excessive whitespace.
+
+ Args:
+ html_content (str): The HTML content to convert to plain text
+
+ Returns:
+ str: Clean plain text without HTML tags, styles, or excessive whitespace
+ """
+ # Remove style tags and their content
+ html_content = re.sub(r"", "", html_content, flags=re.DOTALL | re.IGNORECASE)
+
+ # Strip HTML tags
+ text_content = strip_tags(html_content)
+
+ # Remove excessive empty lines
+ text_content = re.sub(r"\n\s*\n\s*\n+", "\n\n", text_content)
+
+ # Ensure there's a leading and trailing whitespace
+ text_content = "\n\n" + text_content.lstrip().rstrip() + "\n\n"
+
+ return text_content
diff --git a/apps/api/plane/utils/error_codes.py b/apps/api/plane/utils/error_codes.py
index 15d38f6bf96..571f9d36873 100644
--- a/apps/api/plane/utils/error_codes.py
+++ b/apps/api/plane/utils/error_codes.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
ERROR_CODES = {
# issues
"INVALID_ARCHIVE_STATE_GROUP": 4091,
diff --git a/apps/api/plane/utils/exception_logger.py b/apps/api/plane/utils/exception_logger.py
index b0a6f8c38ba..657afeb5caf 100644
--- a/apps/api/plane/utils/exception_logger.py
+++ b/apps/api/plane/utils/exception_logger.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import logging
import traceback
diff --git a/apps/api/plane/utils/exporters/__init__.py b/apps/api/plane/utils/exporters/__init__.py
index 9e7b1a9d51d..632452a3119 100644
--- a/apps/api/plane/utils/exporters/__init__.py
+++ b/apps/api/plane/utils/exporters/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""Export utilities for various data formats."""
from .exporter import Exporter
diff --git a/apps/api/plane/utils/exporters/exporter.py b/apps/api/plane/utils/exporters/exporter.py
index 75b396cb4eb..ff4df46c7fb 100644
--- a/apps/api/plane/utils/exporters/exporter.py
+++ b/apps/api/plane/utils/exporters/exporter.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from typing import Any, Dict, List, Type, Union
from django.db.models import QuerySet
diff --git a/apps/api/plane/utils/exporters/formatters.py b/apps/api/plane/utils/exporters/formatters.py
index fc7c23528b0..611a60fca4f 100644
--- a/apps/api/plane/utils/exporters/formatters.py
+++ b/apps/api/plane/utils/exporters/formatters.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import csv
import io
import json
@@ -5,6 +9,9 @@
from openpyxl import Workbook
+# Module imports
+from plane.utils.csv_utils import sanitize_csv_row
+
class BaseFormatter:
"""Base class for export formatters."""
@@ -80,7 +87,7 @@ def _create_csv_file(self, data: List[List[str]]) -> str:
buf = io.StringIO()
writer = csv.writer(buf, delimiter=",", quoting=csv.QUOTE_ALL)
for row in data:
- writer.writerow(row)
+ writer.writerow(sanitize_csv_row(row))
buf.seek(0)
return buf.getvalue()
diff --git a/apps/api/plane/utils/exporters/schemas/__init__.py b/apps/api/plane/utils/exporters/schemas/__init__.py
index 98b2623aed2..e792b3c6ff3 100644
--- a/apps/api/plane/utils/exporters/schemas/__init__.py
+++ b/apps/api/plane/utils/exporters/schemas/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""Export schemas for various data types."""
from .base import (
diff --git a/apps/api/plane/utils/exporters/schemas/base.py b/apps/api/plane/utils/exporters/schemas/base.py
index 4e67c6980c5..eacee3741a0 100644
--- a/apps/api/plane/utils/exporters/schemas/base.py
+++ b/apps/api/plane/utils/exporters/schemas/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
diff --git a/apps/api/plane/utils/exporters/schemas/issue.py b/apps/api/plane/utils/exporters/schemas/issue.py
index 744e3305249..a3bda90b7b9 100644
--- a/apps/api/plane/utils/exporters/schemas/issue.py
+++ b/apps/api/plane/utils/exporters/schemas/issue.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from collections import defaultdict
from typing import Any, Dict, List, Optional
diff --git a/apps/api/plane/utils/filters/__init__.py b/apps/api/plane/utils/filters/__init__.py
index 76a96c82c07..cdcf8ac6e1a 100644
--- a/apps/api/plane/utils/filters/__init__.py
+++ b/apps/api/plane/utils/filters/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Filters module for handling complex filtering operations
# Import all utilities from base modules
diff --git a/apps/api/plane/utils/filters/converters.py b/apps/api/plane/utils/filters/converters.py
index f7693b40ea0..4d37c2b0b17 100644
--- a/apps/api/plane/utils/filters/converters.py
+++ b/apps/api/plane/utils/filters/converters.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import re
import uuid
from datetime import datetime
diff --git a/apps/api/plane/utils/filters/filter_backend.py b/apps/api/plane/utils/filters/filter_backend.py
index 11ed48f7180..c21560f70f6 100644
--- a/apps/api/plane/utils/filters/filter_backend.py
+++ b/apps/api/plane/utils/filters/filter_backend.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import json
diff --git a/apps/api/plane/utils/filters/filter_migrations.py b/apps/api/plane/utils/filters/filter_migrations.py
index 3e424b6e675..555793dc2a0 100644
--- a/apps/api/plane/utils/filters/filter_migrations.py
+++ b/apps/api/plane/utils/filters/filter_migrations.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
Utilities for migrating legacy filters to rich filters format.
diff --git a/apps/api/plane/utils/filters/filterset.py b/apps/api/plane/utils/filters/filterset.py
index 0099b83d099..721bf4c7afd 100644
--- a/apps/api/plane/utils/filters/filterset.py
+++ b/apps/api/plane/utils/filters/filterset.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import copy
from django.db import models
diff --git a/apps/api/plane/utils/global_paginator.py b/apps/api/plane/utils/global_paginator.py
index 1b7f908c547..e9b68ba7650 100644
--- a/apps/api/plane/utils/global_paginator.py
+++ b/apps/api/plane/utils/global_paginator.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# python imports
from math import ceil
diff --git a/apps/api/plane/utils/grouper.py b/apps/api/plane/utils/grouper.py
index 1ec004e95ad..ab008796715 100644
--- a/apps/api/plane/utils/grouper.py
+++ b/apps/api/plane/utils/grouper.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
diff --git a/apps/api/plane/utils/host.py b/apps/api/plane/utils/host.py
index 860e19e0e3b..dafd19179e4 100644
--- a/apps/api/plane/utils/host.py
+++ b/apps/api/plane/utils/host.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
diff --git a/apps/api/plane/utils/html_processor.py b/apps/api/plane/utils/html_processor.py
index 18d103b6455..a26f6fe13c3 100644
--- a/apps/api/plane/utils/html_processor.py
+++ b/apps/api/plane/utils/html_processor.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from io import StringIO
from html.parser import HTMLParser
diff --git a/apps/api/plane/utils/imports.py b/apps/api/plane/utils/imports.py
index 81de0203bba..af86c31e7dc 100644
--- a/apps/api/plane/utils/imports.py
+++ b/apps/api/plane/utils/imports.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import pkgutil
import six
diff --git a/apps/api/plane/utils/instance_config_variables/__init__.py b/apps/api/plane/utils/instance_config_variables/__init__.py
index 6818ca9bf73..09882ae11c9 100644
--- a/apps/api/plane/utils/instance_config_variables/__init__.py
+++ b/apps/api/plane/utils/instance_config_variables/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from .core import core_config_variables
from .extended import extended_config_variables
diff --git a/apps/api/plane/utils/instance_config_variables/core.py b/apps/api/plane/utils/instance_config_variables/core.py
index cf8d8d41fbe..274c6539af9 100644
--- a/apps/api/plane/utils/instance_config_variables/core.py
+++ b/apps/api/plane/utils/instance_config_variables/core.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
@@ -44,6 +48,12 @@
"category": "GOOGLE",
"is_encrypted": True,
},
+ {
+ "key": "ENABLE_GOOGLE_SYNC",
+ "value": os.environ.get("ENABLE_GOOGLE_SYNC", "0"),
+ "category": "GOOGLE",
+ "is_encrypted": False,
+ },
]
github_config_variables = [
@@ -65,6 +75,12 @@
"category": "GITHUB",
"is_encrypted": False,
},
+ {
+ "key": "ENABLE_GITHUB_SYNC",
+ "value": os.environ.get("ENABLE_GITHUB_SYNC", "0"),
+ "category": "GITHUB",
+ "is_encrypted": False,
+ },
]
@@ -87,6 +103,12 @@
"category": "GITLAB",
"is_encrypted": True,
},
+ {
+ "key": "ENABLE_GITLAB_SYNC",
+ "value": os.environ.get("ENABLE_GITLAB_SYNC", "0"),
+ "category": "GITLAB",
+ "is_encrypted": False,
+ },
]
gitea_config_variables = [
@@ -114,6 +136,12 @@
"category": "GITEA",
"is_encrypted": True,
},
+ {
+ "key": "ENABLE_GITEA_SYNC",
+ "value": os.environ.get("ENABLE_GITEA_SYNC", "0"),
+ "category": "GITEA",
+ "is_encrypted": False,
+ },
]
smtp_config_variables = [
diff --git a/apps/api/plane/utils/instance_config_variables/extended.py b/apps/api/plane/utils/instance_config_variables/extended.py
index 24c6fefda4c..cf267aca241 100644
--- a/apps/api/plane/utils/instance_config_variables/extended.py
+++ b/apps/api/plane/utils/instance_config_variables/extended.py
@@ -1 +1,5 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
extended_config_variables = []
diff --git a/apps/api/plane/utils/ip_address.py b/apps/api/plane/utils/ip_address.py
index 01789c431ef..3a0f171d793 100644
--- a/apps/api/plane/utils/ip_address.py
+++ b/apps/api/plane/utils/ip_address.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
def get_client_ip(request):
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
diff --git a/apps/api/plane/utils/issue_filters.py b/apps/api/plane/utils/issue_filters.py
index 8d56bc38936..ea31a529bb4 100644
--- a/apps/api/plane/utils/issue_filters.py
+++ b/apps/api/plane/utils/issue_filters.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import re
import uuid
from datetime import timedelta
diff --git a/apps/api/plane/utils/issue_relation_mapper.py b/apps/api/plane/utils/issue_relation_mapper.py
index 19d65c1112d..ecce5a2d121 100644
--- a/apps/api/plane/utils/issue_relation_mapper.py
+++ b/apps/api/plane/utils/issue_relation_mapper.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
def get_inverse_relation(relation_type):
relation_mapping = {
"start_after": "start_before",
diff --git a/apps/api/plane/utils/issue_search.py b/apps/api/plane/utils/issue_search.py
index 1e7543d8850..7e5fab8fea3 100644
--- a/apps/api/plane/utils/issue_search.py
+++ b/apps/api/plane/utils/issue_search.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import re
diff --git a/apps/api/plane/utils/logging.py b/apps/api/plane/utils/logging.py
index 083132f1634..61312448d6e 100644
--- a/apps/api/plane/utils/logging.py
+++ b/apps/api/plane/utils/logging.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import logging.handlers as handlers
import time
diff --git a/apps/api/plane/utils/markdown.py b/apps/api/plane/utils/markdown.py
index 188c54fec3b..643dd778863 100644
--- a/apps/api/plane/utils/markdown.py
+++ b/apps/api/plane/utils/markdown.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import mistune
markdown = mistune.Markdown()
diff --git a/apps/api/plane/utils/openapi/__init__.py b/apps/api/plane/utils/openapi/__init__.py
index b2c9ba6b0c0..090d076ecd1 100644
--- a/apps/api/plane/utils/openapi/__init__.py
+++ b/apps/api/plane/utils/openapi/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
OpenAPI utilities for drf-spectacular integration.
@@ -43,6 +47,7 @@
CYCLE_VIEW_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
+ ESTIMATE_ID_PARAMETER,
)
# Responses
@@ -122,6 +127,10 @@
STATE_UPDATE_EXAMPLE,
INTAKE_ISSUE_CREATE_EXAMPLE,
INTAKE_ISSUE_UPDATE_EXAMPLE,
+ ESTIMATE_CREATE_EXAMPLE,
+ ESTIMATE_UPDATE_EXAMPLE,
+ ESTIMATE_POINT_CREATE_EXAMPLE,
+ ESTIMATE_POINT_UPDATE_EXAMPLE,
# Response Examples
CYCLE_EXAMPLE,
TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE,
@@ -141,6 +150,8 @@
PROJECT_MEMBER_EXAMPLE,
CYCLE_ISSUE_EXAMPLE,
STICKY_EXAMPLE,
+ ESTIMATE_EXAMPLE,
+ ESTIMATE_POINT_EXAMPLE,
)
# Helper decorators
@@ -153,6 +164,7 @@
user_docs,
cycle_docs,
work_item_docs,
+ work_item_relation_docs,
label_docs,
issue_link_docs,
issue_comment_docs,
@@ -161,6 +173,8 @@
module_docs,
module_issue_docs,
state_docs,
+ estimate_docs,
+ estimate_point_docs,
)
# Schema processing hooks
@@ -202,6 +216,7 @@
"CYCLE_VIEW_PARAMETER",
"FIELDS_PARAMETER",
"EXPAND_PARAMETER",
+ "ESTIMATE_ID_PARAMETER",
# Responses
"UNAUTHORIZED_RESPONSE",
"FORBIDDEN_RESPONSE",
@@ -275,6 +290,10 @@
"STATE_UPDATE_EXAMPLE",
"INTAKE_ISSUE_CREATE_EXAMPLE",
"INTAKE_ISSUE_UPDATE_EXAMPLE",
+ "ESTIMATE_CREATE_EXAMPLE",
+ "ESTIMATE_UPDATE_EXAMPLE",
+ "ESTIMATE_POINT_CREATE_EXAMPLE",
+ "ESTIMATE_POINT_UPDATE_EXAMPLE",
# Response Examples
"CYCLE_EXAMPLE",
"TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE",
@@ -294,6 +313,8 @@
"PROJECT_MEMBER_EXAMPLE",
"CYCLE_ISSUE_EXAMPLE",
"STICKY_EXAMPLE",
+ "ESTIMATE_EXAMPLE",
+ "ESTIMATE_POINT_EXAMPLE",
# Decorators
"workspace_docs",
"project_docs",
@@ -303,6 +324,7 @@
"user_docs",
"cycle_docs",
"work_item_docs",
+ "work_item_relation_docs",
"label_docs",
"issue_link_docs",
"issue_comment_docs",
@@ -311,6 +333,8 @@
"module_docs",
"module_issue_docs",
"state_docs",
+ "estimate_docs",
+ "estimate_point_docs",
# Hooks
"preprocess_filter_api_v1_paths",
"generate_operation_summary",
diff --git a/apps/api/plane/utils/openapi/auth.py b/apps/api/plane/utils/openapi/auth.py
index 9434956fe8b..6f7459ea2db 100644
--- a/apps/api/plane/utils/openapi/auth.py
+++ b/apps/api/plane/utils/openapi/auth.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
OpenAPI authentication extensions for drf-spectacular.
diff --git a/apps/api/plane/utils/openapi/decorators.py b/apps/api/plane/utils/openapi/decorators.py
index c1ba9612e5c..7ded9fb10b3 100644
--- a/apps/api/plane/utils/openapi/decorators.py
+++ b/apps/api/plane/utils/openapi/decorators.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
Helper decorators for drf-spectacular OpenAPI documentation.
@@ -219,6 +223,21 @@ def issue_attachment_docs(**kwargs):
return extend_schema(**_merge_schema_options(defaults, kwargs))
+def work_item_relation_docs(**kwargs):
+ """Decorator for work item relation endpoints"""
+ defaults = {
+ "tags": ["Work Item Relations"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+
def module_docs(**kwargs):
"""Decorator for module management endpoints"""
defaults = {
@@ -263,6 +282,7 @@ def state_docs(**kwargs):
return extend_schema(**_merge_schema_options(defaults, kwargs))
+
def sticky_docs(**kwargs):
"""Decorator for sticky management endpoints"""
defaults = {
@@ -276,4 +296,30 @@ def sticky_docs(**kwargs):
},
}
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+def estimate_docs(**kwargs):
+ """Decorator for estimate-related endpoints"""
+ defaults = {
+ "tags": ["Estimates"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+def estimate_point_docs(**kwargs):
+ """Decorator for estimate point-related endpoints"""
+ defaults = {
+ "tags": ["Estimate Points"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
return extend_schema(**_merge_schema_options(defaults, kwargs))
\ No newline at end of file
diff --git a/apps/api/plane/utils/openapi/examples.py b/apps/api/plane/utils/openapi/examples.py
index f41bdddbcb9..20aff18958a 100644
--- a/apps/api/plane/utils/openapi/examples.py
+++ b/apps/api/plane/utils/openapi/examples.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
Common OpenAPI examples for drf-spectacular.
@@ -682,6 +686,69 @@
},
)
+# Estimate Examples
+ESTIMATE_EXAMPLE = OpenApiExample(
+ name="Estimate",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "Estimate 1",
+ "description": "Estimate 1 description",
+ },
+ description="Example response for an estimate",
+)
+
+ESTIMATE_POINT_EXAMPLE = OpenApiExample(
+ name="EstimatePoint",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "estimate": "550e8400-e29b-41d4-a716-446655440001",
+ "key": 1,
+ "value": "1",
+ },
+ description="Example response for an estimate point",
+)
+ESTIMATE_CREATE_EXAMPLE = OpenApiExample(
+ name="EstimateCreateSerializer",
+ value={
+ "name": "Estimate 1",
+ "description": "Estimate 1 description",
+ },
+ description="Example request for creating an estimate",
+)
+ESTIMATE_UPDATE_EXAMPLE = OpenApiExample(
+ name="EstimateUpdateSerializer",
+ value={
+ "name": "Estimate 1",
+ "description": "Estimate 1 description",
+ },
+ description="Example request for updating an estimate",
+)
+
+# Estimate Point Examples
+ESTIMATE_POINT_CREATE_EXAMPLE = OpenApiExample(
+ name="EstimatePointCreateSerializer",
+ value=[
+ {
+ "value": "1",
+ "description": "Estimate Point 1 description",
+ },
+ {
+ "value": "2",
+ "description": "Estimate Point 2 description",
+ },
+ ],
+ description="Example request for creating an estimate point",
+)
+ESTIMATE_POINT_UPDATE_EXAMPLE = OpenApiExample(
+ name="EstimatePointUpdateSerializer",
+ value={
+ "value": "1",
+ "description": "Estimate Point 1 description",
+ },
+ description="Example request for updating an estimate point",
+)
+
+
# Sample data for different entity types
SAMPLE_ISSUE = {
"id": "550e8400-e29b-41d4-a716-446655440000",
@@ -797,6 +864,24 @@
"created_at": "2024-01-01T10:30:00Z",
}
+SAMPLE_ESTIMATE = {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "Estimate 1",
+ "description": "Estimate 1 description",
+ "type": "categories",
+ "last_used": False,
+ "created_at": "2024-01-01T10:30:00Z",
+}
+
+SAMPLE_ESTIMATE_POINT = {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "estimate": "550e8400-e29b-41d4-a716-446655440001",
+ "key": 1,
+ "value": "1",
+ "description": "Estimate Point 1 description",
+ "created_at": "2024-01-01T10:30:00Z",
+}
+
# Mapping of schema types to sample data
SCHEMA_EXAMPLES = {
"Issue": SAMPLE_ISSUE,
@@ -812,6 +897,8 @@
"Intake": SAMPLE_INTAKE,
"CycleIssue": SAMPLE_CYCLE_ISSUE,
"Sticky": SAMPLE_STICKY,
+ "Estimate": SAMPLE_ESTIMATE,
+ "EstimatePoint": SAMPLE_ESTIMATE_POINT,
}
diff --git a/apps/api/plane/utils/openapi/hooks.py b/apps/api/plane/utils/openapi/hooks.py
index f136324c0b4..20319285b15 100644
--- a/apps/api/plane/utils/openapi/hooks.py
+++ b/apps/api/plane/utils/openapi/hooks.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
Schema processing hooks for drf-spectacular OpenAPI generation.
diff --git a/apps/api/plane/utils/openapi/parameters.py b/apps/api/plane/utils/openapi/parameters.py
index 47db747ac7f..2812892ec91 100644
--- a/apps/api/plane/utils/openapi/parameters.py
+++ b/apps/api/plane/utils/openapi/parameters.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
Common OpenAPI parameters for drf-spectacular.
@@ -491,3 +495,11 @@
),
],
)
+
+ESTIMATE_ID_PARAMETER = OpenApiParameter(
+ name="estimate_id",
+ description="Estimate ID",
+ required=True,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.PATH,
+)
diff --git a/apps/api/plane/utils/openapi/responses.py b/apps/api/plane/utils/openapi/responses.py
index 2a569e37780..cb0f81dce7d 100644
--- a/apps/api/plane/utils/openapi/responses.py
+++ b/apps/api/plane/utils/openapi/responses.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
Common OpenAPI responses for drf-spectacular.
diff --git a/apps/api/plane/utils/order_queryset.py b/apps/api/plane/utils/order_queryset.py
index 167cd0693d3..abc0bbca0cf 100644
--- a/apps/api/plane/utils/order_queryset.py
+++ b/apps/api/plane/utils/order_queryset.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.db.models import Case, CharField, Min, Value, When
# Custom ordering for priority and state
diff --git a/apps/api/plane/utils/paginator.py b/apps/api/plane/utils/paginator.py
index f3a79475676..5ae4d38150c 100644
--- a/apps/api/plane/utils/paginator.py
+++ b/apps/api/plane/utils/paginator.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import math
from collections import defaultdict
diff --git a/apps/api/plane/utils/path_validator.py b/apps/api/plane/utils/path_validator.py
index ede3f116154..f15fb4ca953 100644
--- a/apps/api/plane/utils/path_validator.py
+++ b/apps/api/plane/utils/path_validator.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Django imports
from django.utils.http import url_has_allowed_host_and_scheme
from django.conf import settings
diff --git a/apps/api/plane/utils/permissions/__init__.py b/apps/api/plane/utils/permissions/__init__.py
index 849f7ba3ee1..22d27694e9f 100644
--- a/apps/api/plane/utils/permissions/__init__.py
+++ b/apps/api/plane/utils/permissions/__init__.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from .workspace import (
WorkSpaceBasePermission,
WorkspaceOwnerPermission,
diff --git a/apps/api/plane/utils/permissions/base.py b/apps/api/plane/utils/permissions/base.py
index a2b1a18ff85..7b243cbb789 100644
--- a/apps/api/plane/utils/permissions/base.py
+++ b/apps/api/plane/utils/permissions/base.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from plane.db.models import WorkspaceMember, ProjectMember
from functools import wraps
from rest_framework.response import Response
diff --git a/apps/api/plane/utils/permissions/page.py b/apps/api/plane/utils/permissions/page.py
index bea878f4c49..844ff4dafbb 100644
--- a/apps/api/plane/utils/permissions/page.py
+++ b/apps/api/plane/utils/permissions/page.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from plane.db.models import ProjectMember, Page
from plane.app.permissions import ROLE
diff --git a/apps/api/plane/utils/permissions/project.py b/apps/api/plane/utils/permissions/project.py
index a8c0f92a27a..55550b27aca 100644
--- a/apps/api/plane/utils/permissions/project.py
+++ b/apps/api/plane/utils/permissions/project.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third Party imports
from rest_framework.permissions import SAFE_METHODS, BasePermission
diff --git a/apps/api/plane/utils/permissions/workspace.py b/apps/api/plane/utils/permissions/workspace.py
index 8dc791c0cc9..ada16ec3b5a 100644
--- a/apps/api/plane/utils/permissions/workspace.py
+++ b/apps/api/plane/utils/permissions/workspace.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Third Party imports
from rest_framework.permissions import BasePermission, SAFE_METHODS
diff --git a/apps/api/plane/utils/porters/__init__.py b/apps/api/plane/utils/porters/__init__.py
new file mode 100644
index 00000000000..5e2cf79e83d
--- /dev/null
+++ b/apps/api/plane/utils/porters/__init__.py
@@ -0,0 +1,19 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
+from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter
+from .exporter import DataExporter
+from .serializers import IssueExportSerializer
+
+__all__ = [
+ # Formatters
+ "BaseFormatter",
+ "CSVFormatter",
+ "JSONFormatter",
+ "XLSXFormatter",
+ # Exporters
+ "DataExporter",
+ # Export Serializers
+ "IssueExportSerializer",
+]
diff --git a/apps/api/plane/utils/porters/exporter.py b/apps/api/plane/utils/porters/exporter.py
new file mode 100644
index 00000000000..394a2bb0fd5
--- /dev/null
+++ b/apps/api/plane/utils/porters/exporter.py
@@ -0,0 +1,107 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
+from typing import Dict, List, Union
+from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter
+
+
+class DataExporter:
+ """
+ Export data using DRF serializers with built-in format support.
+
+ Usage:
+ # New simplified interface
+ exporter = DataExporter(BookSerializer, format_type='csv')
+ filename, content = exporter.export('books_export', queryset)
+
+ # Legacy interface (still supported)
+ exporter = DataExporter(BookSerializer)
+ csv_string = exporter.to_string(queryset, CSVFormatter())
+ """
+
+ # Available formatters
+ FORMATTERS = {
+ "csv": CSVFormatter,
+ "json": JSONFormatter,
+ "xlsx": XLSXFormatter,
+ }
+
+ def __init__(self, serializer_class, format_type: str = None, **serializer_kwargs):
+ """
+ Initialize exporter with serializer and optional format type.
+
+ Args:
+ serializer_class: DRF serializer class to use for data serialization
+ format_type: Optional format type (csv, json, xlsx). If provided, enables export() method.
+ **serializer_kwargs: Additional kwargs to pass to serializer
+ """
+ self.serializer_class = serializer_class
+ self.serializer_kwargs = serializer_kwargs
+ self.format_type = format_type
+ self.formatter = None
+
+ if format_type:
+ if format_type not in self.FORMATTERS:
+ raise ValueError(f"Unsupported format: {format_type}. Available: {list(self.FORMATTERS.keys())}")
+ # Create formatter with default options
+ self.formatter = self._create_formatter(format_type)
+
+ def _create_formatter(self, format_type: str) -> BaseFormatter:
+ """Create formatter instance with appropriate options."""
+ formatter_class = self.FORMATTERS[format_type]
+
+ # Apply format-specific options
+ if format_type == "xlsx":
+ return formatter_class(list_joiner=", ")
+ else:
+ return formatter_class()
+
+ def serialize(self, queryset) -> List[Dict]:
+ """QuerySet → list of dicts"""
+ serializer = self.serializer_class(
+ queryset,
+ many=True,
+ **self.serializer_kwargs
+ )
+ return serializer.data
+
+ def export(self, filename: str, queryset) -> tuple[str, Union[str, bytes]]:
+ """
+ Export queryset to file with configured format.
+
+ Args:
+ filename: Base filename (without extension)
+ queryset: Django QuerySet to export
+
+ Returns:
+ Tuple of (filename_with_extension, content)
+
+ Raises:
+ ValueError: If format_type was not provided during initialization
+ """
+ if not self.formatter:
+ raise ValueError("format_type must be provided during initialization to use export() method")
+
+ data = self.serialize(queryset)
+ content = self.formatter.encode(data)
+ full_filename = f"{filename}.{self.formatter.extension}"
+
+ return full_filename, content
+
+ def to_string(self, queryset, formatter: BaseFormatter) -> Union[str, bytes]:
+ """Export to formatted string (legacy interface)"""
+ data = self.serialize(queryset)
+ return formatter.encode(data)
+
+ def to_file(self, queryset, filepath: str, formatter: BaseFormatter) -> str:
+ """Export to file (legacy interface)"""
+ content = self.to_string(queryset, formatter)
+ with open(filepath, 'w', encoding='utf-8') as f:
+ f.write(content)
+ return filepath
+
+ @classmethod
+ def get_available_formats(cls) -> List[str]:
+ """Get list of available export formats."""
+ return list(cls.FORMATTERS.keys())
diff --git a/apps/api/plane/utils/porters/formatters.py b/apps/api/plane/utils/porters/formatters.py
new file mode 100644
index 00000000000..461a6a5e427
--- /dev/null
+++ b/apps/api/plane/utils/porters/formatters.py
@@ -0,0 +1,274 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
+"""
+Import/Export System with Pluggable Formatters
+
+Exporter: QuerySet → Serializer → Formatter → File/String
+Importer: File/String → Formatter → Serializer → Models
+"""
+
+import csv
+import json
+from abc import ABC, abstractmethod
+from io import BytesIO, StringIO
+from typing import Any, Dict, List, Union
+
+from openpyxl import Workbook, load_workbook
+
+
+# Module imports
+from plane.utils.csv_utils import sanitize_csv_row, sanitize_csv_value
+
+
+class BaseFormatter(ABC):
+ @abstractmethod
+ def encode(self, data: List[Dict]) -> Union[str, bytes]:
+ """Data → formatted string/bytes"""
+ pass
+
+ @abstractmethod
+ def decode(self, content: Union[str, bytes]) -> List[Dict]:
+ """Formatted string/bytes → data"""
+ pass
+
+ @property
+ @abstractmethod
+ def extension(self) -> str:
+ pass
+
+
+class JSONFormatter(BaseFormatter):
+ def __init__(self, indent: int = 2):
+ self.indent = indent
+
+ def encode(self, data: List[Dict]) -> str:
+ return json.dumps(data, indent=self.indent, default=str)
+
+ def decode(self, content: str) -> List[Dict]:
+ return json.loads(content)
+
+ @property
+ def extension(self) -> str:
+ return "json"
+
+
+class CSVFormatter(BaseFormatter):
+ def __init__(self, flatten: bool = True, delimiter: str = ",", prettify_headers: bool = True):
+ """
+ Args:
+ flatten: Whether to flatten nested dicts.
+ delimiter: CSV delimiter character.
+ prettify_headers: If True, transforms 'created_by_name' → 'Created By Name'.
+ """
+ self.flatten = flatten
+ self.delimiter = delimiter
+ self.prettify_headers = prettify_headers
+
+ def _prettify_header(self, header: str) -> str:
+ """Transform 'created_by_name' → 'Created By Name'"""
+ return header.replace("_", " ").title()
+
+ def _normalize_header(self, header: str) -> str:
+ """Transform 'Display Name' → 'display_name' (reverse of prettify)"""
+ return header.strip().lower().replace(" ", "_")
+
+ def _flatten(self, row: Dict, parent_key: str = "") -> Dict:
+ items = {}
+ for key, value in row.items():
+ new_key = f"{parent_key}__{key}" if parent_key else key
+ if isinstance(value, dict):
+ items.update(self._flatten(value, new_key))
+ elif isinstance(value, list):
+ items[new_key] = json.dumps(value)
+ else:
+ items[new_key] = value
+ return items
+
+ def _unflatten(self, row: Dict) -> Dict:
+ result = {}
+ for key, value in row.items():
+ parts = key.split("__")
+ current = result
+ for part in parts[:-1]:
+ current = current.setdefault(part, {})
+
+ if isinstance(value, str):
+ try:
+ parsed = json.loads(value)
+ if isinstance(parsed, (list, dict)):
+ value = parsed
+ except (json.JSONDecodeError, TypeError):
+ pass
+
+ current[parts[-1]] = value
+ return result
+
+ def encode(self, data: List[Dict]) -> str:
+ if not data:
+ return ""
+
+ if self.flatten:
+ data = [self._flatten(row) for row in data]
+
+ # Collect all unique field names in order
+ fieldnames = []
+ for row in data:
+ for key in row.keys():
+ if key not in fieldnames:
+ fieldnames.append(key)
+
+ output = StringIO()
+
+ if self.prettify_headers:
+ # Create header mapping: original_key → Pretty Header
+ header_map = {key: self._prettify_header(key) for key in fieldnames}
+ pretty_headers = [header_map[key] for key in fieldnames]
+
+ # Write pretty headers manually, then write data rows
+ writer = csv.writer(output, delimiter=self.delimiter)
+ writer.writerow(pretty_headers)
+
+ # Write data rows in the same field order
+ for row in data:
+ writer.writerow(sanitize_csv_row([row.get(key, "") for key in fieldnames]))
+ else:
+ writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=self.delimiter)
+ writer.writeheader()
+ for row in data:
+ writer.writerow({k: sanitize_csv_value(row.get(k, "")) for k in fieldnames})
+
+ return output.getvalue()
+
+ def decode(self, content: str, normalize_headers: bool = True) -> List[Dict]:
+ """
+ Decode CSV content to list of dicts.
+
+ Args:
+ content: CSV string
+ normalize_headers: If True, converts 'Display Name' → 'display_name'
+ """
+ rows = list(csv.DictReader(StringIO(content), delimiter=self.delimiter))
+
+ # Normalize headers: 'Email' → 'email', 'Display Name' → 'display_name'
+ if normalize_headers:
+ rows = [{self._normalize_header(k): v for k, v in row.items()} for row in rows]
+
+ if self.flatten:
+ rows = [self._unflatten(row) for row in rows]
+
+ return rows
+
+ @property
+ def extension(self) -> str:
+ return "csv"
+
+
+class XLSXFormatter(BaseFormatter):
+ """Formatter for XLSX (Excel) files using openpyxl."""
+
+ def __init__(self, prettify_headers: bool = True, list_joiner: str = ", "):
+ """
+ Args:
+ prettify_headers: If True, transforms 'created_by_name' → 'Created By Name'.
+ list_joiner: String to join list values (default: ", ").
+ """
+ self.prettify_headers = prettify_headers
+ self.list_joiner = list_joiner
+
+ def _prettify_header(self, header: str) -> str:
+ """Transform 'created_by_name' → 'Created By Name'"""
+ return header.replace("_", " ").title()
+
+ def _normalize_header(self, header: str) -> str:
+ """Transform 'Display Name' → 'display_name' (reverse of prettify)"""
+ return header.strip().lower().replace(" ", "_")
+
+ def _format_value(self, value: Any) -> Any:
+ """Format a value for XLSX cell."""
+ if value is None:
+ return ""
+ if isinstance(value, list):
+ return self.list_joiner.join(str(v) for v in value)
+ if isinstance(value, dict):
+ return json.dumps(value)
+ return value
+
+ def encode(self, data: List[Dict]) -> bytes:
+ """Encode data to XLSX bytes."""
+ wb = Workbook()
+ ws = wb.active
+
+ if not data:
+ # Return empty workbook
+ output = BytesIO()
+ wb.save(output)
+ output.seek(0)
+ return output.getvalue()
+
+ # Collect all unique field names in order
+ fieldnames = []
+ for row in data:
+ for key in row.keys():
+ if key not in fieldnames:
+ fieldnames.append(key)
+
+ # Write header row
+ if self.prettify_headers:
+ headers = [self._prettify_header(key) for key in fieldnames]
+ else:
+ headers = fieldnames
+ ws.append(headers)
+
+ # Write data rows
+ for row in data:
+ ws.append([self._format_value(row.get(key, "")) for key in fieldnames])
+
+ output = BytesIO()
+ wb.save(output)
+ output.seek(0)
+ return output.getvalue()
+
+ def decode(self, content: bytes, normalize_headers: bool = True) -> List[Dict]:
+ """
+ Decode XLSX bytes to list of dicts.
+
+ Args:
+ content: XLSX file bytes
+ normalize_headers: If True, converts 'Display Name' → 'display_name'
+ """
+ wb = load_workbook(filename=BytesIO(content), read_only=True, data_only=True)
+ ws = wb.active
+
+ rows = list(ws.iter_rows(values_only=True))
+ if not rows:
+ return []
+
+ # First row is headers
+ headers = list(rows[0])
+ if normalize_headers:
+ headers = [self._normalize_header(str(h)) if h else "" for h in headers]
+
+ # Convert remaining rows to dicts
+ result = []
+ for row in rows[1:]:
+ row_dict = {}
+ for i, value in enumerate(row):
+ if i < len(headers) and headers[i]:
+ # Try to parse JSON strings back to lists/dicts
+ if isinstance(value, str):
+ try:
+ parsed = json.loads(value)
+ if isinstance(parsed, (list, dict)):
+ value = parsed
+ except (json.JSONDecodeError, TypeError):
+ pass
+ row_dict[headers[i]] = value
+ result.append(row_dict)
+
+ return result
+
+ @property
+ def extension(self) -> str:
+ return "xlsx"
diff --git a/apps/api/plane/utils/porters/serializers/__init__.py b/apps/api/plane/utils/porters/serializers/__init__.py
new file mode 100644
index 00000000000..e4e4bb7623b
--- /dev/null
+++ b/apps/api/plane/utils/porters/serializers/__init__.py
@@ -0,0 +1,10 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
+from .issue import IssueExportSerializer
+
+__all__ = [
+ # Export Serializers
+ "IssueExportSerializer",
+]
diff --git a/apps/api/plane/utils/porters/serializers/issue.py b/apps/api/plane/utils/porters/serializers/issue.py
new file mode 100644
index 00000000000..31be812cc02
--- /dev/null
+++ b/apps/api/plane/utils/porters/serializers/issue.py
@@ -0,0 +1,145 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
+# Third party imports
+from rest_framework import serializers
+
+# Module imports
+from plane.app.serializers import IssueSerializer
+
+
+class IssueExportSerializer(IssueSerializer):
+ """
+ Export-optimized serializer that extends IssueSerializer with human-readable fields.
+
+ Converts UUIDs to readable values for CSV/JSON export.
+ """
+
+ identifier = serializers.SerializerMethodField()
+ project_name = serializers.CharField(source='project.name', read_only=True, default="")
+ project_identifier = serializers.CharField(source='project.identifier', read_only=True, default="")
+ state_name = serializers.CharField(source='state.name', read_only=True, default="")
+ created_by_name = serializers.CharField(source='created_by.full_name', read_only=True, default="")
+
+ assignees = serializers.SerializerMethodField()
+ parent = serializers.SerializerMethodField()
+ labels = serializers.SerializerMethodField()
+ cycles = serializers.SerializerMethodField()
+ modules = serializers.SerializerMethodField()
+ comments = serializers.SerializerMethodField()
+ estimate = serializers.SerializerMethodField()
+ links = serializers.SerializerMethodField()
+ relations = serializers.SerializerMethodField()
+ subscribers = serializers.SerializerMethodField()
+
+ class Meta(IssueSerializer.Meta):
+ fields = [
+ "project_name",
+ "project_identifier",
+ "parent",
+ "identifier",
+ "sequence_id",
+ "name",
+ "state_name",
+ "priority",
+ "assignees",
+ "subscribers",
+ "created_by_name",
+ "start_date",
+ "target_date",
+ "completed_at",
+ "created_at",
+ "updated_at",
+ "archived_at",
+ "estimate",
+ "labels",
+ "cycles",
+ "modules",
+ "links",
+ "relations",
+ "comments",
+ "sub_issues_count",
+ "link_count",
+ "attachment_count",
+ "is_draft",
+ ]
+
+ def get_identifier(self, obj):
+ return f"{obj.project.identifier}-{obj.sequence_id}"
+
+ def get_assignees(self, obj):
+ return [u.full_name for u in obj.assignees.all() if u.is_active]
+
+ def get_subscribers(self, obj):
+ """Return list of subscriber names."""
+ return [sub.subscriber.full_name for sub in obj.issue_subscribers.all() if sub.subscriber]
+
+ def get_parent(self, obj):
+ if not obj.parent:
+ return ""
+ return f"{obj.parent.project.identifier}-{obj.parent.sequence_id}"
+
+ def get_labels(self, obj):
+ return [
+ il.label.name
+ for il in obj.label_issue.all()
+ if il.deleted_at is None
+ ]
+
+ def get_cycles(self, obj):
+ return [ic.cycle.name for ic in obj.issue_cycle.all()]
+
+ def get_modules(self, obj):
+ return [im.module.name for im in obj.issue_module.all()]
+
+ def get_estimate(self, obj):
+ """Return estimate point value."""
+ if obj.estimate_point:
+ return obj.estimate_point.value if hasattr(obj.estimate_point, 'value') else str(obj.estimate_point)
+ return ""
+
+ def get_links(self, obj):
+ """Return list of issue links with titles."""
+ return [
+ {
+ "url": link.url,
+ "title": link.title if link.title else link.url,
+ }
+ for link in obj.issue_link.all()
+ ]
+
+ def get_relations(self, obj):
+ """Return list of related issues."""
+ relations = []
+
+ # Outgoing relations (this issue relates to others)
+ for rel in obj.issue_relation.all():
+ if rel.related_issue:
+ relations.append({
+ "type": rel.relation_type if hasattr(rel, 'relation_type') else "related",
+ "issue": f"{rel.related_issue.project.identifier}-{rel.related_issue.sequence_id}",
+ "direction": "outgoing"
+ })
+
+ # Incoming relations (other issues relate to this one)
+ for rel in obj.issue_related.all():
+ if rel.issue:
+ relations.append({
+ "type": rel.relation_type if hasattr(rel, 'relation_type') else "related",
+ "issue": f"{rel.issue.project.identifier}-{rel.issue.sequence_id}",
+ "direction": "incoming"
+ })
+
+ return relations
+
+ def get_comments(self, obj):
+ """Return list of comments with author and timestamp."""
+ return [
+ {
+ "comment": comment.comment_stripped if hasattr(comment, 'comment_stripped') else comment.comment_html,
+ "created_by": comment.actor.full_name if comment.actor else "",
+ "created_at": comment.created_at.strftime("%Y-%m-%d %H:%M:%S") if comment.created_at else "",
+ }
+ for comment in obj.issue_comments.all()
+ ]
diff --git a/apps/api/plane/utils/telemetry.py b/apps/api/plane/utils/telemetry.py
index bec3d240dd8..e3646eaba14 100644
--- a/apps/api/plane/utils/telemetry.py
+++ b/apps/api/plane/utils/telemetry.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import os
import atexit
diff --git a/apps/api/plane/utils/timezone_converter.py b/apps/api/plane/utils/timezone_converter.py
index 9a66742ed26..81aa3692dbd 100644
--- a/apps/api/plane/utils/timezone_converter.py
+++ b/apps/api/plane/utils/timezone_converter.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import pytz
from datetime import datetime, time
diff --git a/apps/api/plane/utils/url.py b/apps/api/plane/utils/url.py
index 773608bd3d8..8381d65f9c1 100644
--- a/apps/api/plane/utils/url.py
+++ b/apps/api/plane/utils/url.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import re
from typing import Optional
diff --git a/apps/api/plane/utils/uuid.py b/apps/api/plane/utils/uuid.py
index 03f695fdb10..2d95d590648 100644
--- a/apps/api/plane/utils/uuid.py
+++ b/apps/api/plane/utils/uuid.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
# Python imports
import uuid
import hashlib
diff --git a/apps/api/plane/web/__init__.py b/apps/api/plane/web/__init__.py
index e69de29bb2d..917e26db4cb 100644
--- a/apps/api/plane/web/__init__.py
+++ b/apps/api/plane/web/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
diff --git a/apps/api/plane/web/apps.py b/apps/api/plane/web/apps.py
index a5861f9b5ff..1193cd6ae89 100644
--- a/apps/api/plane/web/apps.py
+++ b/apps/api/plane/web/apps.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.apps import AppConfig
diff --git a/apps/api/plane/web/urls.py b/apps/api/plane/web/urls.py
index 28734ad91b6..fe1f8951aee 100644
--- a/apps/api/plane/web/urls.py
+++ b/apps/api/plane/web/urls.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.urls import path
from plane.web.views import robots_txt, health_check
diff --git a/apps/api/plane/web/views.py b/apps/api/plane/web/views.py
index 8acb70a7714..c2c42710e57 100644
--- a/apps/api/plane/web/views.py
+++ b/apps/api/plane/web/views.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
from django.http import HttpResponse, JsonResponse
diff --git a/apps/api/plane/wsgi.py b/apps/api/plane/wsgi.py
index b3051f9ff7b..4c8a7916364 100644
--- a/apps/api/plane/wsgi.py
+++ b/apps/api/plane/wsgi.py
@@ -1,3 +1,7 @@
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
"""
WSGI config for plane project.
diff --git a/apps/api/requirements/base.txt b/apps/api/requirements/base.txt
index b0ffb54e836..865590cb2eb 100644
--- a/apps/api/requirements/base.txt
+++ b/apps/api/requirements/base.txt
@@ -1,7 +1,7 @@
# base requirements
# django
-Django==4.2.28
+Django==4.2.29
# rest framework
djangorestframework==3.15.2
# postgres
@@ -21,7 +21,7 @@ celery==5.4.0
django_celery_beat==2.6.0
django-celery-results==2.5.1
# file serve
-whitenoise==6.6.0
+whitenoise==6.11.0
# fake data
faker==25.0.0
# filters
@@ -45,13 +45,13 @@ scout-apm==3.1.0
# xlsx generation
openpyxl==3.1.2
# logging
-python-json-logger==3.3.0
+python-json-logger==4.0.0
# html parser
beautifulsoup4==4.12.3
# analytics
posthog==3.5.0
# crypto
-cryptography==46.0.5
+cryptography==46.0.6
# html validator
lxml==6.0.0
# s3
@@ -61,7 +61,7 @@ zxcvbn==4.4.28
# timezone
pytz==2024.1
# jwt
-PyJWT==2.8.0
+PyJWT==2.12.0
# OpenTelemetry
opentelemetry-api==1.28.1
opentelemetry-sdk==1.28.1
diff --git a/apps/api/requirements/test.txt b/apps/api/requirements/test.txt
index 66a1ff1638e..c3149753572 100644
--- a/apps/api/requirements/test.txt
+++ b/apps/api/requirements/test.txt
@@ -1,6 +1,6 @@
-r base.txt
# test framework
-pytest==7.4.0
+pytest==9.0.2
pytest-django==4.5.2
pytest-cov==4.1.0
pytest-xdist==3.3.1
@@ -9,4 +9,4 @@ factory-boy==3.3.0
freezegun==1.2.2
coverage==7.2.7
httpx==0.24.1
-requests==2.32.4
\ No newline at end of file
+requests==2.33.0
\ No newline at end of file
diff --git a/apps/api/run_tests.py b/apps/api/run_tests.py
index b92f9fe5bac..886e8a04127 100755
--- a/apps/api/run_tests.py
+++ b/apps/api/run_tests.py
@@ -1,4 +1,8 @@
#!/usr/bin/env python
+# Copyright (c) 2023-present Plane Software, Inc. and contributors
+# SPDX-License-Identifier: AGPL-3.0-only
+# See the LICENSE file for details.
+
import argparse
import subprocess
import sys
diff --git a/apps/api/templates/emails/auth/forgot_password.html b/apps/api/templates/emails/auth/forgot_password.html
index f673c1e6341..29c9b466388 100644
--- a/apps/api/templates/emails/auth/forgot_password.html
+++ b/apps/api/templates/emails/auth/forgot_password.html
@@ -1,330 +1,306 @@
-
-
+
+
-
-
-
-
- Set a new password to your Plane account
-
-
-
-
-
-
+
+
+ Reset your Plane password
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Reset your Plane account's password
-
- |
-
-
- |
-
-
-
-
-
-
-
- Someone, hopefully you, has requested a new password be set to your Plane account. If it was you, please click the button below to reset your password.
-
- |
-
-
- |
-
-
- |
-
- |
-
-
-
-
-
-
-
- Ignore this if you didn't ask for a new link.
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | |
- |
- |
-
-
- | |
-
-
- Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every message, tweet, and conversation and update our public roadmap.
-
- |
- |
-
-
- | |
- |
- |
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
- | |