diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml
index 4e672326c87..4240c10c509 100644
--- a/.github/ISSUE_TEMPLATE/--bug-report.yaml
+++ b/.github/ISSUE_TEMPLATE/--bug-report.yaml
@@ -44,6 +44,15 @@ body:
- Deploy preview
validations:
required: true
+ type: dropdown
+ id: browser
+ attributes:
+ label: Browser
+ options:
+ - Google Chrome
+ - Mozilla Firefox
+ - Safari
+ - Other
- type: dropdown
id: version
attributes:
diff --git a/README.md b/README.md
index e462b278057..102739e4e3d 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@
Meet Plane. An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘♀️.
-> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/29tPNhaV) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
+> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting).
@@ -65,6 +65,7 @@ cd plane
docker-compose up
```
+You can use the default email and password for your first login `captain@plane.so` and `password123`.
## 🚀 Features
@@ -123,14 +124,6 @@ For full documentation, visit [docs.plane.so](https://docs.plane.so/)
To see how to Contribute, visit [here](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md).
-## 🔋 Status
-
-- [x] Early Community Previews: We are open-sourcing and sharing the development version of Plane
-- [ ] Alpha: We are testing Plane with a closed set of customers
-- [ ] Public Alpha: Anyone can sign up over at [app.plane.so](https://app.plane.so). But go easy on us, there are a few hiccups
-- [ ] Public Beta: Stable enough for most non-enterprise use-cases
-- [ ] Public: Production-ready
-
## ❤️ Community
The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects.
diff --git a/apiserver/Procfile b/apiserver/Procfile
index 35f6e9aa83c..30d73491385 100644
--- a/apiserver/Procfile
+++ b/apiserver/Procfile
@@ -1,2 +1,2 @@
web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
-worker: python manage.py rqworker
\ No newline at end of file
+worker: celery -A plane worker -l info
\ No newline at end of file
diff --git a/apiserver/back_migration.py b/apiserver/back_migration.py
index 1ba31293490..c1ccc28b58b 100644
--- a/apiserver/back_migration.py
+++ b/apiserver/back_migration.py
@@ -3,8 +3,7 @@
import random
from django.contrib.auth.hashers import make_password
from plane.db.models import ProjectIdentifier
-from plane.db.models import Issue, IssueComment, User, Project, ProjectMember
-
+from plane.db.models import Issue, IssueComment, User, Project, ProjectMember, Label
# Update description and description html values for old descriptions
@@ -148,7 +147,7 @@ def update_user_view_property():
"collapsed": True,
"issueView": "list",
"filterIssue": None,
- "groupByProperty": True,
+ "groupByProperty": None,
"showEmptyGroups": True,
}
updated_project_members.append(project_member)
@@ -161,6 +160,7 @@ def update_user_view_property():
print(e)
print("Failed")
+
def update_label_color():
try:
labels = Label.objects.filter(color="")
diff --git a/apiserver/bin/worker b/apiserver/bin/worker
index 25a94761325..9d2da1254d8 100755
--- a/apiserver/bin/worker
+++ b/apiserver/bin/worker
@@ -2,5 +2,4 @@
set -e
python manage.py wait_for_db
-python manage.py migrate
-python manage.py rqworker
\ No newline at end of file
+celery -A plane worker -l info
\ No newline at end of file
diff --git a/apiserver/plane/__init__.py b/apiserver/plane/__init__.py
index e69de29bb2d..fb989c4e63d 100644
--- a/apiserver/plane/__init__.py
+++ b/apiserver/plane/__init__.py
@@ -0,0 +1,3 @@
+from .celery import app as celery_app
+
+__all__ = ('celery_app',)
diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py
index 57bff15c239..2adff829998 100644
--- a/apiserver/plane/api/serializers/__init__.py
+++ b/apiserver/plane/api/serializers/__init__.py
@@ -11,6 +11,7 @@
TeamSerializer,
WorkSpaceMemberInviteSerializer,
WorkspaceLiteSerializer,
+ WorkspaceThemeSerializer,
)
from .project import (
ProjectSerializer,
@@ -41,6 +42,7 @@
IssueStateSerializer,
IssueLinkSerializer,
IssueLiteSerializer,
+ IssueAttachmentSerializer,
)
from .module import (
@@ -65,3 +67,5 @@
from .importer import ImporterSerializer
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
+
+from .estimate import EstimateSerializer, EstimatePointSerializer
diff --git a/apiserver/plane/api/serializers/estimate.py b/apiserver/plane/api/serializers/estimate.py
new file mode 100644
index 00000000000..0aa4d331e6d
--- /dev/null
+++ b/apiserver/plane/api/serializers/estimate.py
@@ -0,0 +1,25 @@
+# Module imports
+from .base import BaseSerializer
+
+from plane.db.models import Estimate, EstimatePoint
+
+
+class EstimateSerializer(BaseSerializer):
+ class Meta:
+ model = Estimate
+ fields = "__all__"
+ read_only_fields = [
+ "workspace",
+ "project",
+ ]
+
+
+class EstimatePointSerializer(BaseSerializer):
+ class Meta:
+ model = EstimatePoint
+ fields = "__all__"
+ read_only_fields = [
+ "estimate",
+ "workspace",
+ "project",
+ ]
diff --git a/apiserver/plane/api/serializers/importer.py b/apiserver/plane/api/serializers/importer.py
index 28f2153c847..fcc7da6ce7f 100644
--- a/apiserver/plane/api/serializers/importer.py
+++ b/apiserver/plane/api/serializers/importer.py
@@ -1,11 +1,13 @@
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
+from .project import ProjectLiteSerializer
from plane.db.models import Importer
class ImporterSerializer(BaseSerializer):
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
+ project_detail = ProjectLiteSerializer(source="project", read_only=True)
class Meta:
model = Importer
diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py
index c5d53f8384b..a39128088c7 100644
--- a/apiserver/plane/api/serializers/issue.py
+++ b/apiserver/plane/api/serializers/issue.py
@@ -25,6 +25,7 @@
Module,
ModuleIssue,
IssueLink,
+ IssueAttachment,
)
@@ -99,7 +100,7 @@ def create(self, validated_data):
project = self.context["project"]
issue = Issue.objects.create(**validated_data, project=project)
- if blockers is not None:
+ if blockers is not None and len(blockers):
IssueBlocker.objects.bulk_create(
[
IssueBlocker(
@@ -115,7 +116,7 @@ def create(self, validated_data):
batch_size=10,
)
- if assignees is not None:
+ if assignees is not None and len(assignees):
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
@@ -130,8 +131,19 @@ def create(self, validated_data):
],
batch_size=10,
)
-
- if labels is not None:
+ else:
+ # Then assign it to default assignee
+ if project.default_assignee is not None:
+ IssueAssignee.objects.create(
+ assignee=project.default_assignee,
+ issue=issue,
+ project=project,
+ workspace=project.workspace,
+ created_by=issue.created_by,
+ updated_by=issue.updated_by,
+ )
+
+ if labels is not None and len(labels):
IssueLabel.objects.bulk_create(
[
IssueLabel(
@@ -147,7 +159,7 @@ def create(self, validated_data):
batch_size=10,
)
- if blocks is not None:
+ if blocks is not None and len(blocks):
IssueBlocker.objects.bulk_create(
[
IssueBlocker(
@@ -254,7 +266,8 @@ class Meta:
class IssueCommentSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
- project_detail = ProjectSerializer(read_only=True, source="project")
+ project_detail = ProjectLiteSerializer(read_only=True, source="project")
+ workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
class Meta:
model = IssueComment
@@ -297,6 +310,9 @@ class Meta:
class LabelSerializer(BaseSerializer):
+ workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
+ project_detail = ProjectLiteSerializer(source="project", read_only=True)
+
class Meta:
model = Label
fields = "__all__"
@@ -439,6 +455,21 @@ def create(self, validated_data):
return IssueLink.objects.create(**validated_data)
+class IssueAttachmentSerializer(BaseSerializer):
+ class Meta:
+ model = IssueAttachment
+ fields = "__all__"
+ read_only_fields = [
+ "created_by",
+ "updated_by",
+ "created_at",
+ "updated_at",
+ "workspace",
+ "project",
+ "issue",
+ ]
+
+
# Issue Serializer with state details
class IssueStateSerializer(BaseSerializer):
state_detail = StateSerializer(read_only=True, source="state")
@@ -466,6 +497,7 @@ class IssueSerializer(BaseSerializer):
issue_cycle = IssueCycleDetailSerializer(read_only=True)
issue_module = IssueModuleDetailSerializer(read_only=True)
issue_link = IssueLinkSerializer(read_only=True, many=True)
+ issue_attachment = IssueAttachmentSerializer(read_only=True, many=True)
sub_issues_count = serializers.IntegerField(read_only=True)
class Meta:
@@ -490,6 +522,8 @@ class IssueLiteSerializer(BaseSerializer):
sub_issues_count = serializers.IntegerField(read_only=True)
cycle_id = serializers.UUIDField(read_only=True)
module_id = serializers.UUIDField(read_only=True)
+ attachment_count = serializers.IntegerField(read_only=True)
+ link_count = serializers.IntegerField(read_only=True)
class Meta:
model = Issue
diff --git a/apiserver/plane/api/serializers/view.py b/apiserver/plane/api/serializers/view.py
index 021bcfb72be..076228ae098 100644
--- a/apiserver/plane/api/serializers/view.py
+++ b/apiserver/plane/api/serializers/view.py
@@ -25,22 +25,18 @@ class Meta:
def create(self, validated_data):
query_params = validated_data.get("query_data", {})
-
- if not bool(query_params):
- raise serializers.ValidationError(
- {"query_data": ["Query data field cannot be empty"]}
- )
-
- validated_data["query"] = issue_filters(query_params, "POST")
+ if bool(query_params):
+ validated_data["query"] = issue_filters(query_params, "POST")
+ else:
+ validated_data["query"] = dict()
return IssueView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("query_data", {})
- if not bool(query_params):
- raise serializers.ValidationError(
- {"query_data": ["Query data field cannot be empty"]}
- )
-
+ if bool(query_params):
+ validated_data["query"] = issue_filters(query_params, "POST")
+ else:
+ validated_data["query"] = dict()
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)
diff --git a/apiserver/plane/api/serializers/workspace.py b/apiserver/plane/api/serializers/workspace.py
index 7b3cb189641..4f4d13f76c5 100644
--- a/apiserver/plane/api/serializers/workspace.py
+++ b/apiserver/plane/api/serializers/workspace.py
@@ -5,8 +5,15 @@
from .base import BaseSerializer
from .user import UserLiteSerializer
-from plane.db.models import User, Workspace, WorkspaceMember, Team, TeamMember
-from plane.db.models import Workspace, WorkspaceMember, Team, WorkspaceMemberInvite
+from plane.db.models import (
+ User,
+ Workspace,
+ WorkspaceMember,
+ Team,
+ TeamMember,
+ WorkspaceMemberInvite,
+ WorkspaceTheme,
+)
class WorkSpaceSerializer(BaseSerializer):
@@ -100,3 +107,13 @@ class Meta:
"id",
]
read_only_fields = fields
+
+
+class WorkspaceThemeSerializer(BaseSerializer):
+ class Meta:
+ model = WorkspaceTheme
+ fields = "__all__"
+ read_only_fields = [
+ "workspace",
+ "actor",
+ ]
diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py
index d408be37e56..0e27ce6656b 100644
--- a/apiserver/plane/api/urls.py
+++ b/apiserver/plane/api/urls.py
@@ -42,6 +42,7 @@
UserActivityGraphEndpoint,
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
+ WorkspaceThemeViewSet,
## End Workspaces
# File Assets
FileAssetEndpoint,
@@ -74,10 +75,17 @@
SubIssuesEndpoint,
IssueLinkViewSet,
BulkCreateIssueLabelsEndpoint,
+ IssueAttachmentEndpoint,
## End Issues
# States
StateViewSet,
## End States
+ # Estimates
+ EstimateViewSet,
+ EstimatePointViewSet,
+ ProjectEstimatePointEndpoint,
+ BulkEstimatePointEndpoint,
+ ## End Estimates
# Shortcuts
ShortCutViewSet,
## End Shortcuts
@@ -133,6 +141,7 @@
## End importer
# Search
GlobalSearchEndpoint,
+ IssueSearchEndpoint,
## End Search
# Gpt
GPTIntegrationEndpoint,
@@ -342,6 +351,27 @@
WorkspaceMemberUserViewsEndpoint.as_view(),
name="workspace-member-details",
),
+ path(
+ "workspaces//workspace-themes/",
+ WorkspaceThemeViewSet.as_view(
+ {
+ "get": "list",
+ "post": "create",
+ }
+ ),
+ name="workspace-themes",
+ ),
+ path(
+ "workspaces//workspace-themes//",
+ WorkspaceThemeViewSet.as_view(
+ {
+ "get": "retrieve",
+ "patch": "partial_update",
+ "delete": "destroy",
+ }
+ ),
+ name="workspace-themes",
+ ),
## End Workspaces ##
# Projects
path(
@@ -477,6 +507,62 @@
name="project-state",
),
# End States ##
+ # States
+ path(
+ "workspaces//projects//estimates/",
+ EstimateViewSet.as_view(
+ {
+ "get": "list",
+ "post": "create",
+ }
+ ),
+ name="project-estimates",
+ ),
+ path(
+ "workspaces//projects//estimates//",
+ EstimateViewSet.as_view(
+ {
+ "get": "retrieve",
+ "put": "update",
+ "patch": "partial_update",
+ "delete": "destroy",
+ }
+ ),
+ name="project-estimates",
+ ),
+ path(
+ "workspaces//projects//estimates//estimate-points/",
+ EstimatePointViewSet.as_view(
+ {
+ "get": "list",
+ "post": "create",
+ }
+ ),
+ name="project-estimate-points",
+ ),
+ path(
+ "workspaces//projects//estimates//estimate-points//",
+ EstimatePointViewSet.as_view(
+ {
+ "get": "retrieve",
+ "put": "update",
+ "patch": "partial_update",
+ "delete": "destroy",
+ }
+ ),
+ name="project-estimates",
+ ),
+ path(
+ "workspaces//projects//project-estimates/",
+ ProjectEstimatePointEndpoint.as_view(),
+ name="project-estimate-points",
+ ),
+ path(
+ "workspaces//projects//estimates//bulk-estimate-points/",
+ BulkEstimatePointEndpoint.as_view(),
+ name="bulk-create-estimate-points",
+ ),
+ # End States ##
# Shortcuts
path(
"workspaces//projects//shortcuts/",
@@ -741,6 +827,16 @@
),
name="project-issue-links",
),
+ path(
+ "workspaces//projects//issues//issue-attachments/",
+ IssueAttachmentEndpoint.as_view(),
+ name="project-issue-attachments",
+ ),
+ path(
+ "workspaces//projects//issues//issue-attachments//",
+ IssueAttachmentEndpoint.as_view(),
+ name="project-issue-attachments",
+ ),
## End Issues
## Issue Activity
path(
@@ -1158,6 +1254,11 @@
ImportServiceEndpoint.as_view(),
name="importer",
),
+ path(
+ "workspaces//importers///",
+ ImportServiceEndpoint.as_view(),
+ name="importer",
+ ),
path(
"workspaces//projects//service//importers//",
UpdateServiceImportStatusEndpoint.as_view(),
@@ -1170,6 +1271,11 @@
GlobalSearchEndpoint.as_view(),
name="global-search",
),
+ path(
+ "workspaces//projects//search-issues/",
+ IssueSearchEndpoint.as_view(),
+ name="project-issue-search",
+ ),
## End Search
# Gpt
path(
diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py
index b6171d68ba4..82eb49e444c 100644
--- a/apiserver/plane/api/views/__init__.py
+++ b/apiserver/plane/api/views/__init__.py
@@ -40,6 +40,7 @@
UserActivityGraphEndpoint,
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
+ WorkspaceThemeViewSet,
)
from .state import StateViewSet
from .shortcut import ShortCutViewSet
@@ -69,6 +70,7 @@
SubIssuesEndpoint,
IssueLinkViewSet,
BulkCreateIssueLabelsEndpoint,
+ IssueAttachmentEndpoint,
)
from .auth_extended import (
@@ -125,7 +127,14 @@
CreatedbyOtherPagesEndpoint,
)
-from .search import GlobalSearchEndpoint
+from .search import GlobalSearchEndpoint, IssueSearchEndpoint
from .gpt import GPTIntegrationEndpoint
+
+from .estimate import (
+ EstimateViewSet,
+ EstimatePointViewSet,
+ ProjectEstimatePointEndpoint,
+ BulkEstimatePointEndpoint,
+)
diff --git a/apiserver/plane/api/views/asset.py b/apiserver/plane/api/views/asset.py
index abdee481225..98c9f9cafdb 100644
--- a/apiserver/plane/api/views/asset.py
+++ b/apiserver/plane/api/views/asset.py
@@ -65,6 +65,8 @@ def delete(self, request, workspace_id, asset_key):
class UserAssetsEndpoint(BaseAPIView):
+ parser_classes = (MultiPartParser, FormParser)
+
def get(self, request, asset_key):
try:
files = FileAsset.objects.filter(asset=asset_key, created_by=request.user)
diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py
index 73edb7d1ebc..df95a1b7a98 100644
--- a/apiserver/plane/api/views/cycle.py
+++ b/apiserver/plane/api/views/cycle.py
@@ -28,6 +28,8 @@
CycleIssue,
Issue,
CycleFavorite,
+ IssueLink,
+ IssueAttachment,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
@@ -226,6 +228,20 @@ def list(self, request, slug, project_id, cycle_id):
.prefetch_related("labels")
.order_by(order_by)
.filter(**filters)
+ .annotate(
+ link_count=IssueLink.objects.filter(issue=OuterRef("id"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+ .annotate(
+ attachment_count=IssueAttachment.objects.filter(
+ issue=OuterRef("id")
+ )
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
)
issues_data = IssueStateSerializer(issues, many=True).data
@@ -317,21 +333,19 @@ def create(self, request, slug, project_id, cycle_id):
# Capture Issue Activity
issue_activity.delay(
- {
- "type": "issue.activity",
- "requested_data": json.dumps({"cycles_list": issues}),
- "actor_id": str(self.request.user.id),
- "issue_id": str(self.kwargs.get("pk", None)),
- "project_id": str(self.kwargs.get("project_id", None)),
- "current_instance": json.dumps(
- {
- "updated_cycle_issues": update_cycle_issue_activity,
- "created_cycle_issues": serializers.serialize(
- "json", record_to_create
- ),
- }
- ),
- },
+ type="issue.activity.updated",
+ requested_data=json.dumps({"cycles_list": issues}),
+ actor_id=str(self.request.user.id),
+ issue_id=str(self.kwargs.get("pk", None)),
+ project_id=str(self.kwargs.get("project_id", None)),
+ current_instance=json.dumps(
+ {
+ "updated_cycle_issues": update_cycle_issue_activity,
+ "created_cycle_issues": serializers.serialize(
+ "json", record_to_create
+ ),
+ }
+ ),
)
# Return all Cycle Issues
@@ -370,7 +384,8 @@ def post(self, request, slug, project_id):
cycles = Cycle.objects.filter(
Q(start_date__lte=start_date, end_date__gte=start_date)
- | Q(start_date__gte=end_date, end_date__lte=end_date),
+ | Q(start_date__lte=end_date, end_date__gte=end_date)
+ | Q(start_date__gte=start_date, end_date__lte=end_date),
workspace__slug=slug,
project_id=project_id,
)
diff --git a/apiserver/plane/api/views/estimate.py b/apiserver/plane/api/views/estimate.py
new file mode 100644
index 00000000000..96d0ed1a479
--- /dev/null
+++ b/apiserver/plane/api/views/estimate.py
@@ -0,0 +1,245 @@
+# Django imports
+from django.db import IntegrityError
+
+# Third party imports
+from rest_framework.response import Response
+from rest_framework import status
+from sentry_sdk import capture_exception
+
+# Module imports
+from .base import BaseViewSet, BaseAPIView
+from plane.api.permissions import ProjectEntityPermission
+from plane.db.models import Project, Estimate, EstimatePoint
+from plane.api.serializers import EstimateSerializer, EstimatePointSerializer
+
+
+class EstimateViewSet(BaseViewSet):
+ permission_classes = [
+ ProjectEntityPermission,
+ ]
+ model = Estimate
+ serializer_class = EstimateSerializer
+
+ def get_queryset(self):
+ return (
+ super()
+ .get_queryset()
+ .filter(workspace__slug=self.kwargs.get("slug"))
+ .filter(project_id=self.kwargs.get("project_id"))
+ .filter(project__project_projectmember__member=self.request.user)
+ .select_related("project")
+ .select_related("workspace")
+ .distinct()
+ )
+
+ def perform_create(self, serializer):
+ serializer.save(project_id=self.kwargs.get("project_id"))
+
+
+class EstimatePointViewSet(BaseViewSet):
+ permission_classes = [
+ ProjectEntityPermission,
+ ]
+ model = EstimatePoint
+ serializer_class = EstimatePointSerializer
+
+ def get_queryset(self):
+ return (
+ super()
+ .get_queryset()
+ .filter(workspace__slug=self.kwargs.get("slug"))
+ .filter(project_id=self.kwargs.get("project_id"))
+ .filter(project__project_projectmember__member=self.request.user)
+ .filter(estimate_id=self.kwargs.get("estimate_id"))
+ .select_related("project")
+ .select_related("workspace")
+ .distinct()
+ )
+
+ def perform_create(self, serializer):
+ serializer.save(
+ project_id=self.kwargs.get("project_id"),
+ estimate_id=self.kwargs.get("estimate_id"),
+ )
+
+ def create(self, request, slug, project_id, estimate_id):
+ try:
+ serializer = EstimatePointSerializer(data=request.data)
+ if serializer.is_valid():
+ serializer.save(estimate_id=estimate_id, project_id=project_id)
+ return Response(serializer.data, status=status.HTTP_201_CREATED)
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ except IntegrityError as e:
+ if "already exists" in str(e):
+ return Response(
+ {"error": "The estimate point is already taken"},
+ status=status.HTTP_410_GONE,
+ )
+ else:
+ capture_exception(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ def partial_update(self, request, slug, project_id, estimate_id, pk):
+ try:
+ estimate_point = EstimatePoint.objects.get(
+ pk=pk,
+ estimate_id=estimate_id,
+ project_id=project_id,
+ workspace__slug=slug,
+ )
+ serializer = EstimatePointSerializer(
+ estimate_point, data=request.data, partial=True
+ )
+ if serializer.is_valid():
+ serializer.save(estimate_id=estimate_id, project_id=project_id)
+ return Response(serializer.data, status=status.HTTP_201_CREATED)
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ except EstimatePoint.DoesNotExist:
+ return Response(
+ {"error": "Estimate Point does not exist"},
+ status=status.HTTP_404_NOT_FOUND,
+ )
+ except IntegrityError as e:
+ if "already exists" in str(e):
+ return Response(
+ {"error": "The estimate point value is already taken"},
+ status=status.HTTP_410_GONE,
+ )
+ else:
+ capture_exception(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+class ProjectEstimatePointEndpoint(BaseAPIView):
+ permission_classes = [
+ ProjectEntityPermission,
+ ]
+
+ def get(self, request, slug, project_id):
+ try:
+ project = Project.objects.get(workspace__slug=slug, pk=project_id)
+ if project.estimate_id is not None:
+ estimate_points = EstimatePoint.objects.filter(
+ estimate_id=project.estimate_id,
+ project_id=project_id,
+ workspace__slug=slug,
+ )
+ serializer = EstimatePointSerializer(estimate_points, many=True)
+ return Response(serializer.data, status=status.HTTP_200_OK)
+ return Response([], status=status.HTTP_200_OK)
+ except Exception as e:
+ capture_exception(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+class BulkEstimatePointEndpoint(BaseAPIView):
+ permission_classes = [
+ ProjectEntityPermission,
+ ]
+
+ def post(self, request, slug, project_id, estimate_id):
+ try:
+ estimate = Estimate.objects.get(
+ pk=estimate_id, workspace__slug=slug, project=project_id
+ )
+
+ estimate_points = request.data.get("estimate_points", [])
+
+ if not len(estimate_points) or len(estimate_points) > 8:
+ return Response(
+ {"error": "Estimate points are required"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ estimate_points = EstimatePoint.objects.bulk_create(
+ [
+ EstimatePoint(
+ estimate=estimate,
+ key=estimate_point.get("key", 0),
+ value=estimate_point.get("value", ""),
+ description=estimate_point.get("description", ""),
+ project_id=project_id,
+ workspace_id=estimate.workspace_id,
+ created_by=request.user,
+ updated_by=request.user,
+ )
+ for estimate_point in estimate_points
+ ],
+ batch_size=10,
+ ignore_conflicts=True,
+ )
+
+ serializer = EstimatePointSerializer(estimate_points, many=True)
+
+ return Response(serializer.data, status=status.HTTP_200_OK)
+ except Estimate.DoesNotExist:
+ return Response(
+ {"error": "Estimate does not exist"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ except Exception as e:
+ capture_exception(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ def patch(self, request, slug, project_id, estimate_id):
+ try:
+ if not len(request.data.get("estimate_points", [])):
+ return Response(
+ {"error": "Estimate points are required"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ estimate_points_data = request.data.get("estimate_points", [])
+
+ estimate_points = EstimatePoint.objects.filter(
+ pk__in=[
+ estimate_point.get("id") for estimate_point in estimate_points_data
+ ],
+ workspace__slug=slug,
+ project_id=project_id,
+ estimate_id=estimate_id,
+ )
+
+ print(estimate_points)
+ updated_estimate_points = []
+ for estimate_point in estimate_points:
+ # Find the data for that estimate point
+ estimate_point_data = [
+ point
+ for point in estimate_points_data
+ if point.get("id") == str(estimate_point.id)
+ ]
+ print(estimate_point_data)
+ if len(estimate_point_data):
+ estimate_point.value = estimate_point_data[0].get(
+ "value", estimate_point.value
+ )
+ updated_estimate_points.append(estimate_point)
+
+ EstimatePoint.objects.bulk_update(
+ updated_estimate_points, ["value"], batch_size=10
+ )
+ serializer = EstimatePointSerializer(estimate_points, many=True)
+ return Response(serializer.data, status=status.HTTP_200_OK)
+ except Estimate.DoesNotExist:
+ return Response(
+ {"error": "Estimate does not exist"}, status=status.HTTP_400_BAD_REQUEST
+ )
+ except Exception as e:
+ print(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
diff --git a/apiserver/plane/api/views/importer.py b/apiserver/plane/api/views/importer.py
index dd52d2dd228..a51af9c226e 100644
--- a/apiserver/plane/api/views/importer.py
+++ b/apiserver/plane/api/views/importer.py
@@ -65,29 +65,35 @@ def get(self, request, slug, service):
)
if service == "jira":
- project_name = request.data.get("project_name", "")
- api_token = request.data.get("api_token", "")
- email = request.data.get("email", "")
- cloud_hostname = request.data.get("cloud_hostname", "")
- if (
- not bool(project_name)
- or not bool(api_token)
- or not bool(email)
- or not bool(cloud_hostname)
- ):
+ # Check for all the keys
+ params = {
+ "project_key": "Project key is required",
+ "api_token": "API token is required",
+ "email": "Email is required",
+ "cloud_hostname": "Cloud hostname is required",
+ }
+
+ for key, error_message in params.items():
+ if not request.GET.get(key, False):
+ return Response(
+ {"error": error_message}, status=status.HTTP_400_BAD_REQUEST
+ )
+
+ project_key = request.GET.get("project_key", "")
+ api_token = request.GET.get("api_token", "")
+ email = request.GET.get("email", "")
+ cloud_hostname = request.GET.get("cloud_hostname", "")
+
+ response = jira_project_issue_summary(
+ email, api_token, project_key, cloud_hostname
+ )
+ if "error" in response:
+ return Response(response, status=status.HTTP_400_BAD_REQUEST)
+ else:
return Response(
- {
- "error": "Project name, Project key, API token, Cloud hostname and email are requied"
- },
- status=status.HTTP_400_BAD_REQUEST,
+ response,
+ status=status.HTTP_200_OK,
)
-
- return Response(
- jira_project_issue_summary(
- email, api_token, project_name, cloud_hostname
- ),
- status=status.HTTP_200_OK,
- )
return Response(
{"error": "Service not supported yet"},
status=status.HTTP_400_BAD_REQUEST,
@@ -213,8 +219,10 @@ def post(self, request, slug, service):
def get(self, request, slug):
try:
- imports = Importer.objects.filter(workspace__slug=slug).order_by(
- "-created_at"
+ imports = (
+ Importer.objects.filter(workspace__slug=slug)
+ .order_by("-created_at")
+ .select_related("initiated_by", "project", "workspace")
)
serializer = ImporterSerializer(imports, many=True)
return Response(serializer.data)
@@ -225,6 +233,20 @@ def get(self, request, slug):
status=status.HTTP_400_BAD_REQUEST,
)
+ def delete(self, request, slug, service, pk):
+ try:
+ importer = Importer.objects.filter(
+ pk=pk, service=service, workspace__slug=slug
+ )
+ importer.delete()
+ return Response(status=status.HTTP_204_NO_CONTENT)
+ except Exception as e:
+ capture_exception(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
class UpdateServiceImportStatusEndpoint(BaseAPIView):
def post(self, request, slug, project_id, service, importer_id):
diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py
index d22c650926a..1f604d271f3 100644
--- a/apiserver/plane/api/views/issue.py
+++ b/apiserver/plane/api/views/issue.py
@@ -12,6 +12,7 @@
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
+from rest_framework.parsers import MultiPartParser, FormParser
from sentry_sdk import capture_exception
# Module imports
@@ -28,6 +29,7 @@
IssueFlatSerializer,
IssueLinkSerializer,
IssueLiteSerializer,
+ IssueAttachmentSerializer,
)
from plane.api.permissions import (
ProjectEntityPermission,
@@ -43,6 +45,7 @@
IssueProperty,
Label,
IssueLink,
+ IssueAttachment,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
@@ -82,16 +85,14 @@ def perform_update(self, serializer):
)
if current_instance is not None:
issue_activity.delay(
- {
- "type": "issue.activity.updated",
- "requested_data": requested_data,
- "actor_id": str(self.request.user.id),
- "issue_id": str(self.kwargs.get("pk", None)),
- "project_id": str(self.kwargs.get("project_id", None)),
- "current_instance": json.dumps(
- IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
- ),
- },
+ type="issue.activity.updated",
+ requested_data=requested_data,
+ actor_id=str(self.request.user.id),
+ issue_id=str(self.kwargs.get("pk", None)),
+ project_id=str(self.kwargs.get("project_id", None)),
+ current_instance=json.dumps(
+ IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
+ ),
)
return super().perform_update(serializer)
@@ -102,18 +103,16 @@ def perform_destroy(self, instance):
)
if current_instance is not None:
issue_activity.delay(
- {
- "type": "issue.activity.deleted",
- "requested_data": json.dumps(
- {"issue_id": str(self.kwargs.get("pk", None))}
- ),
- "actor_id": str(self.request.user.id),
- "issue_id": str(self.kwargs.get("pk", None)),
- "project_id": str(self.kwargs.get("project_id", None)),
- "current_instance": json.dumps(
- IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
- ),
- },
+ type="issue.activity.deleted",
+ requested_data=json.dumps(
+ {"issue_id": str(self.kwargs.get("pk", None))}
+ ),
+ actor_id=str(self.request.user.id),
+ issue_id=str(self.kwargs.get("pk", None)),
+ project_id=str(self.kwargs.get("project_id", None)),
+ current_instance=json.dumps(
+ IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
+ ),
)
return super().perform_destroy(instance)
@@ -149,6 +148,20 @@ def list(self, request, slug, project_id):
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__id"))
.annotate(module_id=F("issue_module__id"))
+ .annotate(
+ link_count=IssueLink.objects.filter(issue=OuterRef("id"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+ .annotate(
+ attachment_count=IssueAttachment.objects.filter(
+ issue=OuterRef("id")
+ )
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
)
issue_queryset = (
@@ -187,16 +200,12 @@ def create(self, request, slug, project_id):
# Track the issue
issue_activity.delay(
- {
- "type": "issue.activity.created",
- "requested_data": json.dumps(
- self.request.data, cls=DjangoJSONEncoder
- ),
- "actor_id": str(request.user.id),
- "issue_id": str(serializer.data.get("id", None)),
- "project_id": str(project_id),
- "current_instance": None,
- },
+ type="issue.activity.created",
+ requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
+ actor_id=str(request.user.id),
+ issue_id=str(serializer.data.get("id", None)),
+ project_id=str(project_id),
+ current_instance=None,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -237,6 +246,20 @@ def get(self, request, slug):
.prefetch_related("assignees")
.prefetch_related("labels")
.order_by("-created_at")
+ .annotate(
+ link_count=IssueLink.objects.filter(issue=OuterRef("id"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+ .annotate(
+ attachment_count=IssueAttachment.objects.filter(
+ issue=OuterRef("id")
+ )
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
)
serializer = IssueLiteSerializer(issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -328,14 +351,12 @@ def perform_create(self, serializer):
actor=self.request.user if self.request.user is not None else None,
)
issue_activity.delay(
- {
- "type": "comment.activity.created",
- "requested_data": json.dumps(serializer.data, cls=DjangoJSONEncoder),
- "actor_id": str(self.request.user.id),
- "issue_id": str(self.kwargs.get("issue_id")),
- "project_id": str(self.kwargs.get("project_id")),
- "current_instance": None,
- },
+ type="comment.activity.created",
+ requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
+ actor_id=str(self.request.user.id),
+ issue_id=str(self.kwargs.get("issue_id")),
+ project_id=str(self.kwargs.get("project_id")),
+ current_instance=None,
)
def perform_update(self, serializer):
@@ -345,17 +366,15 @@ def perform_update(self, serializer):
)
if current_instance is not None:
issue_activity.delay(
- {
- "type": "comment.activity.updated",
- "requested_data": requested_data,
- "actor_id": str(self.request.user.id),
- "issue_id": str(self.kwargs.get("issue_id", None)),
- "project_id": str(self.kwargs.get("project_id", None)),
- "current_instance": json.dumps(
- IssueCommentSerializer(current_instance).data,
- cls=DjangoJSONEncoder,
- ),
- },
+ type="comment.activity.updated",
+ requested_data=requested_data,
+ actor_id=str(self.request.user.id),
+ issue_id=str(self.kwargs.get("issue_id", None)),
+ project_id=str(self.kwargs.get("project_id", None)),
+ current_instance=json.dumps(
+ IssueCommentSerializer(current_instance).data,
+ cls=DjangoJSONEncoder,
+ ),
)
return super().perform_update(serializer)
@@ -366,19 +385,17 @@ def perform_destroy(self, instance):
)
if current_instance is not None:
issue_activity.delay(
- {
- "type": "comment.activity.deleted",
- "requested_data": json.dumps(
- {"comment_id": str(self.kwargs.get("pk", None))}
- ),
- "actor_id": str(self.request.user.id),
- "issue_id": str(self.kwargs.get("issue_id", None)),
- "project_id": str(self.kwargs.get("project_id", None)),
- "current_instance": json.dumps(
- IssueCommentSerializer(current_instance).data,
- cls=DjangoJSONEncoder,
- ),
- },
+ type="comment.activity.deleted",
+ requested_data=json.dumps(
+ {"comment_id": str(self.kwargs.get("pk", None))}
+ ),
+ actor_id=str(self.request.user.id),
+ issue_id=str(self.kwargs.get("issue_id", None)),
+ project_id=str(self.kwargs.get("project_id", None)),
+ current_instance=json.dumps(
+ IssueCommentSerializer(current_instance).data,
+ cls=DjangoJSONEncoder,
+ ),
)
return super().perform_destroy(instance)
@@ -632,6 +649,54 @@ def perform_create(self, serializer):
project_id=self.kwargs.get("project_id"),
issue_id=self.kwargs.get("issue_id"),
)
+ issue_activity.delay(
+ type="link.activity.created",
+ requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
+ actor_id=str(self.request.user.id),
+ issue_id=str(self.kwargs.get("issue_id")),
+ project_id=str(self.kwargs.get("project_id")),
+ current_instance=None,
+ )
+
+ def perform_update(self, serializer):
+ requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
+ current_instance = (
+ self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
+ )
+ if current_instance is not None:
+ issue_activity.delay(
+ type="link.activity.updated",
+ requested_data=requested_data,
+ actor_id=str(self.request.user.id),
+ issue_id=str(self.kwargs.get("issue_id", None)),
+ project_id=str(self.kwargs.get("project_id", None)),
+ current_instance=json.dumps(
+ IssueLinkSerializer(current_instance).data,
+ cls=DjangoJSONEncoder,
+ ),
+ )
+
+ return super().perform_update(serializer)
+
+ def perform_destroy(self, instance):
+ current_instance = (
+ self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
+ )
+ if current_instance is not None:
+ issue_activity.delay(
+ type="link.activity.deleted",
+ requested_data=json.dumps(
+ {"link_id": str(self.kwargs.get("pk", None))}
+ ),
+ actor_id=str(self.request.user.id),
+ issue_id=str(self.kwargs.get("issue_id", None)),
+ project_id=str(self.kwargs.get("project_id", None)),
+ current_instance=json.dumps(
+ IssueLinkSerializer(current_instance).data,
+ cls=DjangoJSONEncoder,
+ ),
+ )
+ return super().perform_destroy(instance)
def get_queryset(self):
return (
@@ -683,3 +748,72 @@ def post(self, request, slug, project_id):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+
+
+class IssueAttachmentEndpoint(BaseAPIView):
+ serializer_class = IssueAttachmentSerializer
+ permission_classes = [
+ ProjectEntityPermission,
+ ]
+ model = IssueAttachment
+ parser_classes = (MultiPartParser, FormParser)
+
+ def post(self, request, slug, project_id, issue_id):
+ try:
+ serializer = IssueAttachmentSerializer(data=request.data)
+ if serializer.is_valid():
+ serializer.save(project_id=project_id, issue_id=issue_id)
+ issue_activity.delay(
+ type="attachment.activity.created",
+ requested_data=None,
+ actor_id=str(self.request.user.id),
+ issue_id=str(self.kwargs.get("issue_id", None)),
+ project_id=str(self.kwargs.get("project_id", None)),
+ current_instance=json.dumps(
+ serializer.data,
+ cls=DjangoJSONEncoder,
+ ),
+ )
+ return Response(serializer.data, status=status.HTTP_201_CREATED)
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ except Exception as e:
+ capture_exception(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ def delete(self, request, slug, project_id, issue_id, pk):
+ try:
+ issue_attachment = IssueAttachment.objects.get(pk=pk)
+ issue_attachment.asset.delete(save=False)
+ issue_attachment.delete()
+ issue_activity.delay(
+ type="attachment.activity.deleted",
+ requested_data=None,
+ actor_id=str(self.request.user.id),
+ issue_id=str(self.kwargs.get("issue_id", None)),
+ project_id=str(self.kwargs.get("project_id", None)),
+ current_instance=None,
+ )
+
+ return Response(status=status.HTTP_204_NO_CONTENT)
+ except IssueAttachment.DoesNotExist:
+ return Response(
+ {"error": "Issue Attachment does not exist"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ def get(self, request, slug, project_id, issue_id):
+ try:
+ issue_attachments = IssueAttachment.objects.filter(
+ issue_id=issue_id, workspace__slug=slug, project_id=project_id
+ )
+ serilaizer = IssueAttachmentSerializer(issue_attachments, many=True)
+ return Response(serilaizer.data, status=status.HTTP_200_OK)
+ except Exception as e:
+ capture_exception(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py
index 3cdb54f7069..0abf47c8b8d 100644
--- a/apiserver/plane/api/views/module.py
+++ b/apiserver/plane/api/views/module.py
@@ -31,6 +31,8 @@
Issue,
ModuleLink,
ModuleFavorite,
+ IssueLink,
+ IssueAttachment,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
@@ -204,6 +206,20 @@ def list(self, request, slug, project_id, module_id):
.prefetch_related("labels")
.order_by(order_by)
.filter(**filters)
+ .annotate(
+ link_count=IssueLink.objects.filter(issue=OuterRef("id"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+ .annotate(
+ attachment_count=IssueAttachment.objects.filter(
+ issue=OuterRef("id")
+ )
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
)
issues_data = IssueStateSerializer(issues, many=True).data
@@ -286,21 +302,19 @@ def create(self, request, slug, project_id, module_id):
# Capture Issue Activity
issue_activity.delay(
- {
- "type": "issue.activity",
- "requested_data": json.dumps({"modules_list": issues}),
- "actor_id": str(self.request.user.id),
- "issue_id": str(self.kwargs.get("pk", None)),
- "project_id": str(self.kwargs.get("project_id", None)),
- "current_instance": json.dumps(
- {
- "updated_module_issues": update_module_issue_activity,
- "created_module_issues": serializers.serialize(
- "json", record_to_create
- ),
- }
- ),
- },
+ type="issue.activity.updated",
+ requested_data=json.dumps({"modules_list": issues}),
+ actor_id=str(self.request.user.id),
+ issue_id=str(self.kwargs.get("pk", None)),
+ project_id=str(self.kwargs.get("project_id", None)),
+ current_instance=json.dumps(
+ {
+ "updated_module_issues": update_module_issue_activity,
+ "created_module_issues": serializers.serialize(
+ "json", record_to_create
+ ),
+ }
+ ),
)
return Response(
diff --git a/apiserver/plane/api/views/search.py b/apiserver/plane/api/views/search.py
index ba75eac91dd..f73a3c9c802 100644
--- a/apiserver/plane/api/views/search.py
+++ b/apiserver/plane/api/views/search.py
@@ -12,6 +12,7 @@
# Module imports
from .base import BaseAPIView
from plane.db.models import Workspace, Project, Issue, Cycle, Module, Page, IssueView
+from plane.utils.issue_search import search_issues
class GlobalSearchEndpoint(BaseAPIView):
@@ -24,20 +25,26 @@ def filter_workspaces(self, query, slug, project_id):
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
- return Workspace.objects.filter(
- q, workspace_member__member=self.request.user
- ).distinct().values("name", "id", "slug")
+ return (
+ Workspace.objects.filter(q, workspace_member__member=self.request.user)
+ .distinct()
+ .values("name", "id", "slug")
+ )
def filter_projects(self, query, slug, project_id):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
- return Project.objects.filter(
- q,
- Q(project_projectmember__member=self.request.user) | Q(network=2),
- workspace__slug=slug,
- ).distinct().values("name", "id", "identifier", "workspace__slug")
+ return (
+ Project.objects.filter(
+ q,
+ Q(project_projectmember__member=self.request.user) | Q(network=2),
+ workspace__slug=slug,
+ )
+ .distinct()
+ .values("name", "id", "identifier", "workspace__slug")
+ )
def filter_issues(self, query, slug, project_id):
fields = ["name", "sequence_id"]
@@ -49,18 +56,22 @@ def filter_issues(self, query, slug, project_id):
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
- return Issue.objects.filter(
- q,
- project__project_projectmember__member=self.request.user,
- workspace__slug=slug,
- project_id=project_id,
- ).distinct().values(
- "name",
- "id",
- "sequence_id",
- "project__identifier",
- "project_id",
- "workspace__slug",
+ return (
+ Issue.objects.filter(
+ q,
+ project__project_projectmember__member=self.request.user,
+ workspace__slug=slug,
+ project_id=project_id,
+ )
+ .distinct()
+ .values(
+ "name",
+ "id",
+ "sequence_id",
+ "project__identifier",
+ "project_id",
+ "workspace__slug",
+ )
)
def filter_cycles(self, query, slug, project_id):
@@ -68,16 +79,20 @@ def filter_cycles(self, query, slug, project_id):
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
- return Cycle.objects.filter(
- q,
- project__project_projectmember__member=self.request.user,
- workspace__slug=slug,
- project_id=project_id,
- ).distinct().values(
- "name",
- "id",
- "project_id",
- "workspace__slug",
+ return (
+ Cycle.objects.filter(
+ q,
+ project__project_projectmember__member=self.request.user,
+ workspace__slug=slug,
+ project_id=project_id,
+ )
+ .distinct()
+ .values(
+ "name",
+ "id",
+ "project_id",
+ "workspace__slug",
+ )
)
def filter_modules(self, query, slug, project_id):
@@ -85,16 +100,20 @@ def filter_modules(self, query, slug, project_id):
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
- return Module.objects.filter(
- q,
- project__project_projectmember__member=self.request.user,
- workspace__slug=slug,
- project_id=project_id,
- ).distinct().values(
- "name",
- "id",
- "project_id",
- "workspace__slug",
+ return (
+ Module.objects.filter(
+ q,
+ project__project_projectmember__member=self.request.user,
+ workspace__slug=slug,
+ project_id=project_id,
+ )
+ .distinct()
+ .values(
+ "name",
+ "id",
+ "project_id",
+ "workspace__slug",
+ )
)
def filter_pages(self, query, slug, project_id):
@@ -102,16 +121,20 @@ def filter_pages(self, query, slug, project_id):
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
- return Page.objects.filter(
- q,
- project__project_projectmember__member=self.request.user,
- workspace__slug=slug,
- project_id=project_id,
- ).distinct().values(
- "name",
- "id",
- "project_id",
- "workspace__slug",
+ return (
+ Page.objects.filter(
+ q,
+ project__project_projectmember__member=self.request.user,
+ workspace__slug=slug,
+ project_id=project_id,
+ )
+ .distinct()
+ .values(
+ "name",
+ "id",
+ "project_id",
+ "workspace__slug",
+ )
)
def filter_views(self, query, slug, project_id):
@@ -119,16 +142,20 @@ def filter_views(self, query, slug, project_id):
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
- return IssueView.objects.filter(
- q,
- project__project_projectmember__member=self.request.user,
- workspace__slug=slug,
- project_id=project_id,
- ).distinct().values(
- "name",
- "id",
- "project_id",
- "workspace__slug",
+ return (
+ IssueView.objects.filter(
+ q,
+ project__project_projectmember__member=self.request.user,
+ workspace__slug=slug,
+ project_id=project_id,
+ )
+ .distinct()
+ .values(
+ "name",
+ "id",
+ "project_id",
+ "workspace__slug",
+ )
)
def get(self, request, slug, project_id):
@@ -173,3 +200,53 @@ def get(self, request, slug, project_id):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+
+
+class IssueSearchEndpoint(BaseAPIView):
+ def get(self, request, slug, project_id):
+ try:
+ query = request.query_params.get("search", False)
+ parent = request.query_params.get("parent", False)
+ blocker_blocked_by = request.query_params.get("blocker_blocked_by", False)
+ issue_id = request.query_params.get("issue_id", False)
+
+ issues = search_issues(query)
+ issues = issues.filter(
+ workspace__slug=slug,
+ project_id=project_id,
+ project__project_projectmember__member=self.request.user,
+ )
+
+ if parent == "true" and issue_id:
+ issue = Issue.objects.get(pk=issue_id)
+ issues = issues.filter(
+ ~Q(pk=issue_id), ~Q(pk=issue.parent_id), parent__isnull=True
+ ).exclude(
+ pk__in=Issue.objects.filter(parent__isnull=False).values_list(
+ "parent_id", flat=True
+ )
+ )
+ if blocker_blocked_by == "true" and issue_id:
+ issues = issues.filter(blocker_issues=issue_id, blocked_issues=issue_id)
+
+ return Response(
+ issues.values(
+ "name",
+ "id",
+ "sequence_id",
+ "project__identifier",
+ "project_id",
+ "workspace__slug",
+ ),
+ status=status.HTTP_200_OK,
+ )
+ except Issue.DoesNotExist:
+ return Response(
+ {"error": "Issue Does not exist"}, status=status.HTTP_400_BAD_REQUEST
+ )
+ except Exception as e:
+ capture_exception(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py
index a1c18f99587..915ade2fc8a 100644
--- a/apiserver/plane/api/views/workspace.py
+++ b/apiserver/plane/api/views/workspace.py
@@ -36,6 +36,7 @@
WorkSpaceMemberInviteSerializer,
UserLiteSerializer,
ProjectMemberSerializer,
+ WorkspaceThemeSerializer,
)
from plane.api.views.base import BaseAPIView
from . import BaseViewSet
@@ -48,6 +49,7 @@
ProjectMember,
IssueActivity,
Issue,
+ WorkspaceTheme,
)
from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission
from plane.bgtasks.workspace_invitation_task import workspace_invitation
@@ -752,3 +754,35 @@ def get(self, request, slug):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+
+
+class WorkspaceThemeViewSet(BaseViewSet):
+ permission_classes = [
+ WorkSpaceAdminPermission,
+ ]
+ model = WorkspaceTheme
+ serializer_class = WorkspaceThemeSerializer
+
+ def get_queryset(self):
+ return super().get_queryset().filter(workspace__slug=self.kwargs.get("slug"))
+
+ def create(self, request, slug):
+ try:
+ workspace = Workspace.objects.get(slug=slug)
+ serializer = WorkspaceThemeSerializer(data=request.data)
+ if serializer.is_valid():
+ serializer.save(workspace=workspace, actor=request.user)
+ return Response(serializer.data, status=status.HTTP_201_CREATED)
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ except Workspace.DoesNotExist:
+ return Response(
+ {"error": "Workspace does not exist"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ except Exception as e:
+ print(e)
+ return Response(
+ {"error": "Something went wrong please try again later"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
diff --git a/apiserver/plane/bgtasks/celery.py b/apiserver/plane/bgtasks/celery.py
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/apiserver/plane/bgtasks/email_verification_task.py b/apiserver/plane/bgtasks/email_verification_task.py
index cf233c531a8..ee4680e53fd 100644
--- a/apiserver/plane/bgtasks/email_verification_task.py
+++ b/apiserver/plane/bgtasks/email_verification_task.py
@@ -4,14 +4,16 @@
from django.utils.html import strip_tags
# Third party imports
-from django_rq import job
+from celery import shared_task
+
+
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import User
-@job("default")
+@shared_task
def email_verification(first_name, email, token, current_site):
try:
diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py
index 7d169e8cff7..4598e5f2f9a 100644
--- a/apiserver/plane/bgtasks/forgot_password_task.py
+++ b/apiserver/plane/bgtasks/forgot_password_task.py
@@ -4,14 +4,14 @@
from django.utils.html import strip_tags
# Third party imports
-from django_rq import job
+from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import User
-@job("default")
+@shared_task
def forgot_password(first_name, email, uidb64, token, current_site):
try:
diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py
index f5dadf322ff..fba43f6e4b7 100644
--- a/apiserver/plane/bgtasks/importer_task.py
+++ b/apiserver/plane/bgtasks/importer_task.py
@@ -11,7 +11,7 @@
from django.contrib.auth.hashers import make_password
# Third Party imports
-from django_rq import job
+from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
@@ -29,7 +29,7 @@
from .workspace_invitation_task import workspace_invitation
-@job("default")
+@shared_task
def service_importer(service, importer_id):
try:
importer = Importer.objects.get(pk=importer_id)
@@ -38,54 +38,55 @@ def service_importer(service, importer_id):
users = importer.data.get("users", [])
- # For all invited users create the uers
- new_users = User.objects.bulk_create(
- [
- User(
- email=user.get("email").strip().lower(),
- username=uuid.uuid4().hex,
- password=make_password(uuid.uuid4().hex),
- is_password_autoset=True,
- )
- for user in users
- if user.get("import", False) == "invite"
- ],
- batch_size=10,
- ignore_conflicts=True,
- )
-
- workspace_users = User.objects.filter(
- email__in=[
- user.get("email").strip().lower()
- for user in users
- if user.get("import", False) == "invite"
- or user.get("import", False) == "map"
- ]
- )
-
-
- # Add new users to Workspace and project automatically
- WorkspaceMember.objects.bulk_create(
- [
- WorkspaceMember(member=user, workspace_id=importer.workspace_id)
- for user in workspace_users
- ],
- batch_size=100,
- ignore_conflicts=True,
- )
-
- ProjectMember.objects.bulk_create(
- [
- ProjectMember(
- project_id=importer.project_id,
- workspace_id=importer.workspace_id,
- member=user,
- )
- for user in workspace_users
- ],
- batch_size=100,
- ignore_conflicts=True,
- )
+ # Check if we need to import users as well
+ if len(users):
+ # For all invited users create the uers
+ new_users = User.objects.bulk_create(
+ [
+ User(
+ email=user.get("email").strip().lower(),
+ username=uuid.uuid4().hex,
+ password=make_password(uuid.uuid4().hex),
+ is_password_autoset=True,
+ )
+ for user in users
+ if user.get("import", False) == "invite"
+ ],
+ batch_size=10,
+ ignore_conflicts=True,
+ )
+
+ workspace_users = User.objects.filter(
+ email__in=[
+ user.get("email").strip().lower()
+ for user in users
+ if user.get("import", False) == "invite"
+ or user.get("import", False) == "map"
+ ]
+ )
+
+ # Add new users to Workspace and project automatically
+ WorkspaceMember.objects.bulk_create(
+ [
+ WorkspaceMember(member=user, workspace_id=importer.workspace_id)
+ for user in workspace_users
+ ],
+ batch_size=100,
+ ignore_conflicts=True,
+ )
+
+ ProjectMember.objects.bulk_create(
+ [
+ ProjectMember(
+ project_id=importer.project_id,
+ workspace_id=importer.workspace_id,
+ member=user,
+ )
+ for user in workspace_users
+ ],
+ batch_size=100,
+ ignore_conflicts=True,
+ )
# Check if sync config is on for github importers
if service == "github" and importer.config.get("sync", False):
diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py
index efc7f196ec9..c4fde964688 100644
--- a/apiserver/plane/bgtasks/issue_activites_task.py
+++ b/apiserver/plane/bgtasks/issue_activites_task.py
@@ -7,7 +7,7 @@
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
-from django_rq import job
+from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
@@ -136,6 +136,7 @@ def track_priority(
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
)
)
+ print(issue_activities)
# Track chnages in state of the issue
@@ -633,6 +634,40 @@ def create_issue_activity(
)
+def track_estimate_points(
+ requested_data, current_instance, issue_id, project, actor, issue_activities
+):
+ if current_instance.get("estimate_point") != requested_data.get("estimate_point"):
+ if requested_data.get("estimate_point") == None:
+ issue_activities.append(
+ IssueActivity(
+ issue_id=issue_id,
+ actor=actor,
+ verb="updated",
+ old_value=current_instance.get("estimate_point"),
+ new_value=requested_data.get("estimate_point"),
+ field="estimate_point",
+ project=project,
+ workspace=project.workspace,
+ comment=f"{actor.email} updated the estimate point to None",
+ )
+ )
+ else:
+ issue_activities.append(
+ IssueActivity(
+ issue_id=issue_id,
+ actor=actor,
+ verb="updated",
+ old_value=current_instance.get("estimate_point"),
+ new_value=requested_data.get("estimate_point"),
+ field="estimate_point",
+ project=project,
+ workspace=project.workspace,
+ comment=f"{actor.email} updated the estimate point to {requested_data.get('estimate_point')}",
+ )
+ )
+
+
def update_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
@@ -650,7 +685,14 @@ def update_issue_activity(
"blockers_list": track_blockings,
"cycles_list": track_cycles,
"modules_list": track_modules,
+ "estimate_point": track_estimate_points,
}
+
+ requested_data = json.loads(requested_data) if requested_data is not None else None
+ current_instance = (
+ json.loads(current_instance) if current_instance is not None else None
+ )
+
for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
if func is not None:
@@ -664,9 +706,29 @@ def update_issue_activity(
)
+def delete_issue_activity(
+ requested_data, current_instance, issue_id, project, actor, issue_activities
+):
+ issue_activities.append(
+ IssueActivity(
+ project=project,
+ workspace=project.workspace,
+ comment=f"{actor.email} deleted the issue",
+ verb="deleted",
+ actor=actor,
+ field="issue",
+ )
+ )
+
+
def create_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
+ requested_data = json.loads(requested_data) if requested_data is not None else None
+ current_instance = (
+ json.loads(current_instance) if current_instance is not None else None
+ )
+
issue_activities.append(
IssueActivity(
issue_id=issue_id,
@@ -686,6 +748,11 @@ def create_comment_activity(
def update_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
+ requested_data = json.loads(requested_data) if requested_data is not None else None
+ current_instance = (
+ json.loads(current_instance) if current_instance is not None else None
+ )
+
if current_instance.get("comment_html") != requested_data.get("comment_html"):
issue_activities.append(
IssueActivity(
@@ -705,59 +772,135 @@ def update_comment_activity(
)
-def delete_issue_activity(
+def delete_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
issue_activities.append(
IssueActivity(
+ issue_id=issue_id,
project=project,
workspace=project.workspace,
- comment=f"{actor.email} deleted the issue",
+ comment=f"{actor.email} deleted the comment",
verb="deleted",
actor=actor,
- field="issue",
+ field="comment",
)
)
-def delete_comment_activity(
+def create_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
+ requested_data = json.loads(requested_data) if requested_data is not None else None
+ current_instance = (
+ json.loads(current_instance) if current_instance is not None else None
+ )
+
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
- comment=f"{actor.email} deleted the comment",
+ comment=f"{actor.email} created a link",
+ verb="created",
+ actor=actor,
+ field="link",
+ new_value=requested_data.get("url", ""),
+ new_identifier=requested_data.get("id", None),
+ )
+ )
+
+
+def update_link_activity(
+ requested_data, current_instance, issue_id, project, actor, issue_activities
+):
+ requested_data = json.loads(requested_data) if requested_data is not None else None
+ current_instance = (
+ json.loads(current_instance) if current_instance is not None else None
+ )
+
+ if current_instance.get("url") != requested_data.get("url"):
+ issue_activities.append(
+ IssueActivity(
+ issue_id=issue_id,
+ project=project,
+ workspace=project.workspace,
+ comment=f"{actor.email} updated a link",
+ verb="updated",
+ actor=actor,
+ field="link",
+ old_value=current_instance.get("url", ""),
+ old_identifier=current_instance.get("id"),
+ new_value=requested_data.get("url", ""),
+ new_identifier=current_instance.get("id", None),
+ )
+ )
+
+
+def delete_link_activity(
+ requested_data, current_instance, issue_id, project, actor, issue_activities
+):
+ issue_activities.append(
+ IssueActivity(
+ issue_id=issue_id,
+ project=project,
+ workspace=project.workspace,
+ comment=f"{actor.email} deleted the link",
verb="deleted",
actor=actor,
- field="comment",
+ field="link",
+ )
+ )
+
+
+def create_attachment_activity(
+ requested_data, current_instance, issue_id, project, actor, issue_activities
+):
+ requested_data = json.loads(requested_data) if requested_data is not None else None
+ current_instance = (
+ json.loads(current_instance) if current_instance is not None else None
+ )
+
+ issue_activities.append(
+ IssueActivity(
+ issue_id=issue_id,
+ project=project,
+ workspace=project.workspace,
+ comment=f"{actor.email} created an attachment",
+ verb="created",
+ actor=actor,
+ field="attachment",
+ new_value=current_instance.get("access", ""),
+ new_identifier=current_instance.get("id", None),
+ )
+ )
+
+
+def delete_attachment_activity(
+ requested_data, current_instance, issue_id, project, actor, issue_activities
+):
+ issue_activities.append(
+ IssueActivity(
+ issue_id=issue_id,
+ project=project,
+ workspace=project.workspace,
+ comment=f"{actor.email} deleted the attachment",
+ verb="deleted",
+ actor=actor,
+ field="attachment",
)
)
# Receive message from room group
-@job("default")
-def issue_activity(event):
+@shared_task
+def issue_activity(
+ type, requested_data, current_instance, issue_id, actor_id, project_id
+):
try:
issue_activities = []
- type = event.get("type")
- requested_data = (
- json.loads(event.get("requested_data"))
- if event.get("current_instance") is not None
- else None
- )
- current_instance = (
- json.loads(event.get("current_instance"))
- if event.get("current_instance") is not None
- else None
- )
- issue_id = event.get("issue_id", None)
- actor_id = event.get("actor_id")
- project_id = event.get("project_id")
actor = User.objects.get(pk=actor_id)
-
project = Project.objects.get(pk=project_id)
ACTIVITY_MAPPER = {
@@ -767,6 +910,11 @@ def issue_activity(event):
"comment.activity.created": create_comment_activity,
"comment.activity.updated": update_comment_activity,
"comment.activity.deleted": delete_comment_activity,
+ "link.activity.created": create_link_activity,
+ "link.activity.updated": update_link_activity,
+ "link.activity.deleted": delete_link_activity,
+ "attachment.activity.created": create_attachment_activity,
+ "attachment.activity.deleted": delete_attachment_activity,
}
func = ACTIVITY_MAPPER.get(type)
@@ -799,5 +947,6 @@ def issue_activity(event):
)
return
except Exception as e:
+ print(e)
capture_exception(e)
return
diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py
index 89239e87d8e..89554dcca3e 100644
--- a/apiserver/plane/bgtasks/magic_link_code_task.py
+++ b/apiserver/plane/bgtasks/magic_link_code_task.py
@@ -4,13 +4,12 @@
from django.utils.html import strip_tags
# Third party imports
-from django_rq import job
+from celery import shared_task
from sentry_sdk import capture_exception
-@job("default")
+@shared_task
def magic_link(email, key, token, current_site):
-
try:
realtivelink = f"/magic-sign-in/?password={token}&key={key}"
abs_url = "http://" + current_site + realtivelink
diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py
index 681438851f8..18e53997054 100644
--- a/apiserver/plane/bgtasks/project_invitation_task.py
+++ b/apiserver/plane/bgtasks/project_invitation_task.py
@@ -4,18 +4,16 @@
from django.utils.html import strip_tags
# Third party imports
-from django_rq import job
+from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import Project, User, ProjectMemberInvite
-@job("default")
+@shared_task
def project_invitation(email, project_id, token, current_site):
-
try:
-
project = Project.objects.get(pk=project_id)
project_member_invite = ProjectMemberInvite.objects.get(
token=token, email=email
@@ -35,7 +33,9 @@ def project_invitation(email, project_id, token, current_site):
"invitation_url": abs_url,
}
- html_content = render_to_string("emails/invitations/project_invitation.html", context)
+ html_content = render_to_string(
+ "emails/invitations/project_invitation.html", context
+ )
text_content = strip_tags(html_content)
diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py
index 0ed80717166..c6e69689bad 100644
--- a/apiserver/plane/bgtasks/workspace_invitation_task.py
+++ b/apiserver/plane/bgtasks/workspace_invitation_task.py
@@ -5,7 +5,7 @@
from django.conf import settings
# Third party imports
-from django_rq import job
+from celery import shared_task
from sentry_sdk import capture_exception
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
@@ -14,7 +14,7 @@
from plane.db.models import Workspace, User, WorkspaceMemberInvite
-@job("default")
+@shared_task
def workspace_invitation(email, workspace_id, token, current_site, invitor):
try:
workspace = Workspace.objects.get(pk=workspace_id)
diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py
new file mode 100644
index 00000000000..1fbbdd732bd
--- /dev/null
+++ b/apiserver/plane/celery.py
@@ -0,0 +1,17 @@
+import os
+from celery import Celery
+from plane.settings.redis import redis_instance
+
+# Set the default Django settings module for the 'celery' program.
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
+
+ri = redis_instance()
+
+app = Celery("plane")
+
+# Using a string here means the worker will not have to
+# pickle the object when using Windows.
+app.config_from_object("django.conf:settings", namespace="CELERY")
+
+# Load task modules from all registered Django app configs.
+app.autodiscover_tasks()
diff --git a/apiserver/plane/db/migrations/0026_alter_projectmember_view_props.py b/apiserver/plane/db/migrations/0026_alter_projectmember_view_props.py
new file mode 100644
index 00000000000..6f74fa49951
--- /dev/null
+++ b/apiserver/plane/db/migrations/0026_alter_projectmember_view_props.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.18 on 2023-04-04 21:50
+
+from django.db import migrations, models
+import plane.db.models.project
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('db', '0025_auto_20230331_0203'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='projectmember',
+ name='view_props',
+ field=models.JSONField(default=plane.db.models.project.get_default_props),
+ ),
+ ]
\ No newline at end of file
diff --git a/apiserver/plane/db/migrations/0027_auto_20230409_0312.py b/apiserver/plane/db/migrations/0027_auto_20230409_0312.py
new file mode 100644
index 00000000000..8d344cf3403
--- /dev/null
+++ b/apiserver/plane/db/migrations/0027_auto_20230409_0312.py
@@ -0,0 +1,97 @@
+# Generated by Django 3.2.18 on 2023-04-08 21:42
+
+from django.conf import settings
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import plane.db.models.issue
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('db', '0026_alter_projectmember_view_props'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Estimate',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
+ ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
+ ('name', models.CharField(max_length=255)),
+ ('description', models.TextField(blank=True, verbose_name='Estimate Description')),
+ ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='estimate_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
+ ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_estimate', to='db.project')),
+ ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='estimate_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
+ ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_estimate', to='db.workspace')),
+ ],
+ options={
+ 'verbose_name': 'Estimate',
+ 'verbose_name_plural': 'Estimates',
+ 'db_table': 'estimates',
+ 'ordering': ('name',),
+ 'unique_together': {('name', 'project')},
+ },
+ ),
+ migrations.RemoveField(
+ model_name='issue',
+ name='attachments',
+ ),
+ migrations.AddField(
+ model_name='issue',
+ name='estimate_point',
+ field=models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(7)]),
+ ),
+ migrations.CreateModel(
+ name='IssueAttachment',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
+ ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
+ ('attributes', models.JSONField(default=dict)),
+ ('asset', models.FileField(upload_to=plane.db.models.issue.get_upload_path, validators=[plane.db.models.issue.file_size])),
+ ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueattachment_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
+ ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_attachment', to='db.issue')),
+ ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueattachment', to='db.project')),
+ ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueattachment_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
+ ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueattachment', to='db.workspace')),
+ ],
+ options={
+ 'verbose_name': 'Issue Attachment',
+ 'verbose_name_plural': 'Issue Attachments',
+ 'db_table': 'issue_attachments',
+ 'ordering': ('-created_at',),
+ },
+ ),
+ migrations.AddField(
+ model_name='project',
+ name='estimate',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projects', to='db.estimate'),
+ ),
+ migrations.CreateModel(
+ name='EstimatePoint',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
+ ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
+ ('key', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(7)])),
+ ('description', models.TextField(blank=True)),
+ ('value', models.CharField(max_length=20)),
+ ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='estimatepoint_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
+ ('estimate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='points', to='db.estimate')),
+ ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_estimatepoint', to='db.project')),
+ ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='estimatepoint_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
+ ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_estimatepoint', to='db.workspace')),
+ ],
+ options={
+ 'verbose_name': 'Estimate Point',
+ 'verbose_name_plural': 'Estimate Points',
+ 'db_table': 'estimate_points',
+ 'ordering': ('value',),
+ 'unique_together': {('value', 'estimate')},
+ },
+ ),
+ ]
diff --git a/apiserver/plane/db/migrations/0028_auto_20230414_1703.py b/apiserver/plane/db/migrations/0028_auto_20230414_1703.py
new file mode 100644
index 00000000000..bb0b67b92aa
--- /dev/null
+++ b/apiserver/plane/db/migrations/0028_auto_20230414_1703.py
@@ -0,0 +1,48 @@
+# Generated by Django 3.2.18 on 2023-04-14 11:33
+
+from django.conf import settings
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('db', '0027_auto_20230409_0312'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='user',
+ name='theme',
+ field=models.JSONField(default=dict),
+ ),
+ migrations.AlterField(
+ model_name='issue',
+ name='estimate_point',
+ field=models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(7)]),
+ ),
+ migrations.CreateModel(
+ name='WorkspaceTheme',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
+ ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
+ ('name', models.CharField(max_length=300)),
+ ('colors', models.JSONField(default=dict)),
+ ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='themes', to=settings.AUTH_USER_MODEL)),
+ ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacetheme_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
+ ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacetheme_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
+ ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='themes', to='db.workspace')),
+ ],
+ options={
+ 'verbose_name': 'Workspace Theme',
+ 'verbose_name_plural': 'Workspace Themes',
+ 'db_table': 'workspace_themes',
+ 'ordering': ('-created_at',),
+ 'unique_together': {('workspace', 'name')},
+ },
+ ),
+ ]
diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py
index 8a302174143..b6ffe428c0a 100644
--- a/apiserver/plane/db/models/__init__.py
+++ b/apiserver/plane/db/models/__init__.py
@@ -8,6 +8,7 @@
Team,
WorkspaceMemberInvite,
TeamMember,
+ WorkspaceTheme,
)
from .project import (
@@ -32,6 +33,7 @@
IssueBlocker,
IssueLink,
IssueSequence,
+ IssueAttachment,
)
from .asset import FileAsset
@@ -61,4 +63,6 @@
from .importer import Importer
-from .page import Page, PageBlock, PageFavorite, PageLabel
\ No newline at end of file
+from .page import Page, PageBlock, PageFavorite, PageLabel
+
+from .estimate import Estimate, EstimatePoint
diff --git a/apiserver/plane/db/models/estimate.py b/apiserver/plane/db/models/estimate.py
new file mode 100644
index 00000000000..f163a14072b
--- /dev/null
+++ b/apiserver/plane/db/models/estimate.py
@@ -0,0 +1,46 @@
+# Django imports
+from django.db import models
+from django.core.validators import MinValueValidator, MaxValueValidator
+
+# Module imports
+from . import ProjectBaseModel
+
+
+class Estimate(ProjectBaseModel):
+ name = models.CharField(max_length=255)
+ description = models.TextField(verbose_name="Estimate Description", blank=True)
+
+ def __str__(self):
+ """Return name of the estimate"""
+ return f"{self.name} <{self.project.name}>"
+
+ class Meta:
+ unique_together = ["name", "project"]
+ verbose_name = "Estimate"
+ verbose_name_plural = "Estimates"
+ db_table = "estimates"
+ ordering = ("name",)
+
+
+class EstimatePoint(ProjectBaseModel):
+ estimate = models.ForeignKey(
+ "db.Estimate",
+ on_delete=models.CASCADE,
+ related_name="points",
+ )
+ key = models.IntegerField(
+ default=0, validators=[MinValueValidator(0), MaxValueValidator(7)]
+ )
+ description = models.TextField(blank=True)
+ value = models.CharField(max_length=20)
+
+ def __str__(self):
+ """Return name of the estimate"""
+ return f"{self.estimate.name} <{self.key}> <{self.value}>"
+
+ class Meta:
+ unique_together = ["value", "estimate"]
+ verbose_name = "Estimate Point"
+ verbose_name_plural = "Estimate Points"
+ db_table = "estimate_points"
+ ordering = ("value",)
diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py
index 655a03e75ce..fed946a613c 100644
--- a/apiserver/plane/db/models/issue.py
+++ b/apiserver/plane/db/models/issue.py
@@ -1,3 +1,6 @@
+# Python import
+from uuid import uuid4
+
# Django imports
from django.contrib.postgres.fields import ArrayField
from django.db import models
@@ -5,6 +8,8 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
+from django.core.validators import MinValueValidator, MaxValueValidator
+from django.core.exceptions import ValidationError
# Module imports
from . import ProjectBaseModel
@@ -33,6 +38,9 @@ class Issue(ProjectBaseModel):
blank=True,
related_name="state_issue",
)
+ estimate_point = models.IntegerField(
+ validators=[MinValueValidator(0), MaxValueValidator(7)], null=True, blank=True
+ )
name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="")
@@ -54,7 +62,6 @@ class Issue(ProjectBaseModel):
through_fields=("issue", "assignee"),
)
sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID")
- attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
labels = models.ManyToManyField(
"db.Label", blank=True, related_name="labels", through="IssueLabel"
)
@@ -194,6 +201,38 @@ def __str__(self):
return f"{self.issue.name} {self.url}"
+def get_upload_path(instance, filename):
+ return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
+
+
+def file_size(value):
+ limit = 5 * 1024 * 1024
+ if value.size > limit:
+ raise ValidationError("File too large. Size should not exceed 5 MB.")
+
+
+class IssueAttachment(ProjectBaseModel):
+ attributes = models.JSONField(default=dict)
+ asset = models.FileField(
+ upload_to=get_upload_path,
+ validators=[
+ file_size,
+ ],
+ )
+ issue = models.ForeignKey(
+ "db.Issue", on_delete=models.CASCADE, related_name="issue_attachment"
+ )
+
+ class Meta:
+ verbose_name = "Issue Attachment"
+ verbose_name_plural = "Issue Attachments"
+ db_table = "issue_attachments"
+ ordering = ("-created_at",)
+
+ def __str__(self):
+ return f"{self.issue.name} {self.asset}"
+
+
class IssueActivity(ProjectBaseModel):
issue = models.ForeignKey(
Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity"
diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py
index b3c8f669ad8..04435cadf2c 100644
--- a/apiserver/plane/db/models/project.py
+++ b/apiserver/plane/db/models/project.py
@@ -26,7 +26,7 @@ def get_default_props():
"collapsed": True,
"issueView": "list",
"filterIssue": None,
- "groupByProperty": True,
+ "groupByProperty": None,
"showEmptyGroups": True,
}
@@ -69,6 +69,9 @@ class Project(BaseModel):
issue_views_view = models.BooleanField(default=True)
page_view = models.BooleanField(default=True)
cover_image = models.URLField(blank=True, null=True, max_length=800)
+ estimate = models.ForeignKey(
+ "db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True
+ )
def __str__(self):
"""Return name of the project"""
@@ -130,7 +133,7 @@ class ProjectMember(ProjectBaseModel):
)
comment = models.TextField(blank=True, null=True)
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
- view_props = models.JSONField(null=True)
+ view_props = models.JSONField(default=get_default_props)
default_props = models.JSONField(default=get_default_props)
class Meta:
diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py
index 8a30981f37c..334ec3e1396 100644
--- a/apiserver/plane/db/models/user.py
+++ b/apiserver/plane/db/models/user.py
@@ -72,6 +72,7 @@ class User(AbstractBaseUser, PermissionsMixin):
my_issues_prop = models.JSONField(null=True)
role = models.CharField(max_length=300, null=True, blank=True)
is_bot = models.BooleanField(default=False)
+ theme = models.JSONField(default=dict)
USERNAME_FIELD = "email"
diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py
index 5715bb304bc..b00d530132d 100644
--- a/apiserver/plane/db/models/workspace.py
+++ b/apiserver/plane/db/models/workspace.py
@@ -36,7 +36,6 @@ class Meta:
ordering = ("-created_at",)
-
class WorkspaceMember(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member"
@@ -111,7 +110,6 @@ class Meta:
class TeamMember(BaseModel):
-
workspace = models.ForeignKey(
Workspace, on_delete=models.CASCADE, related_name="team_member"
)
@@ -129,3 +127,24 @@ class Meta:
verbose_name_plural = "Team Members"
db_table = "team_members"
ordering = ("-created_at",)
+
+
+class WorkspaceTheme(BaseModel):
+ workspace = models.ForeignKey(
+ "db.Workspace", on_delete=models.CASCADE, related_name="themes"
+ )
+ name = models.CharField(max_length=300)
+ actor = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes"
+ )
+ colors = models.JSONField(default=dict)
+
+ def __str__(self):
+ return str(self.name) + str(self.actor.email)
+
+ class Meta:
+ unique_together = ["workspace", "name"]
+ verbose_name = "Workspace Theme"
+ verbose_name_plural = "Workspace Themes"
+ db_table = "workspace_themes"
+ ordering = ("-created_at",)
diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py
index 73c3c4be543..c144eeb0b91 100644
--- a/apiserver/plane/settings/common.py
+++ b/apiserver/plane/settings/common.py
@@ -35,7 +35,6 @@
"rest_framework_simplejwt.token_blacklist",
"corsheaders",
"taggit",
- "django_rq",
]
MIDDLEWARE = [
@@ -208,3 +207,7 @@
"SLIDING_TOKEN_LIFETIME": timedelta(minutes=5),
"SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
}
+
+CELERY_TIMEZONE = TIME_ZONE
+CELERY_TASK_SERIALIZER = 'json'
+CELERY_ACCEPT_CONTENT = ['application/json']
\ No newline at end of file
diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py
index bf161568b73..c3bf65588d8 100644
--- a/apiserver/plane/settings/local.py
+++ b/apiserver/plane/settings/local.py
@@ -59,16 +59,8 @@
REDIS_HOST = "localhost"
REDIS_PORT = 6379
-REDIS_URL = False
+REDIS_URL = os.environ.get("REDIS_URL")
-RQ_QUEUES = {
- "default": {
- "HOST": "localhost",
- "PORT": 6379,
- "DB": 0,
- "DEFAULT_TIMEOUT": 360,
- },
-}
MEDIA_URL = "/uploads/"
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
@@ -88,3 +80,6 @@
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
+
+CELERY_RESULT_BACKEND = os.environ.get("REDIS_URL")
+CELERY_BROKER_URL = os.environ.get("REDIS_URL")
diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py
index 5569e1c09b5..8f8453aff4e 100644
--- a/apiserver/plane/settings/production.py
+++ b/apiserver/plane/settings/production.py
@@ -1,5 +1,7 @@
"""Production settings and globals."""
from urllib.parse import urlparse
+import ssl
+import certifi
import dj_database_url
from urllib.parse import urlparse
@@ -236,3 +238,9 @@
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
+
+redis_url = os.environ.get("REDIS_URL")
+broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
+
+CELERY_RESULT_BACKEND = broker_url
+CELERY_BROKER_URL = broker_url
\ No newline at end of file
diff --git a/apiserver/plane/settings/redis.py b/apiserver/plane/settings/redis.py
index b32cf8c8056..4e906c4a1dd 100644
--- a/apiserver/plane/settings/redis.py
+++ b/apiserver/plane/settings/redis.py
@@ -1,23 +1,25 @@
+import os
import redis
from django.conf import settings
from urllib.parse import urlparse
+
def redis_instance():
- # Run in local redis url is false
- if not settings.REDIS_URL:
- ri = redis.StrictRedis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0)
+ # connect to redis
+ if (
+ settings.DOCKERIZED
+ or os.environ.get("DJANGO_SETTINGS_MODULE", "plane.settings.production")
+ == "plane.settings.local"
+ ):
+ ri = redis.Redis.from_url(settings.REDIS_URL, db=0)
else:
- # Run in prod redis url is true check with dockerized value
- if settings.DOCKERIZED:
- ri = redis.from_url(settings.REDIS_URL, db=0)
- else:
- url = urlparse(settings.REDIS_URL)
- ri = redis.Redis(
- host=url.hostname,
- port=url.port,
- password=url.password,
- ssl=True,
- ssl_cert_reqs=None,
- )
-
- return ri
\ No newline at end of file
+ url = urlparse(settings.REDIS_URL)
+ ri = redis.Redis(
+ host=url.hostname,
+ port=url.port,
+ password=url.password,
+ ssl=True,
+ ssl_cert_reqs=None,
+ )
+
+ return ri
diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py
index 9015ce03fa6..384116ba34b 100644
--- a/apiserver/plane/settings/staging.py
+++ b/apiserver/plane/settings/staging.py
@@ -1,5 +1,7 @@
"""Production settings and globals."""
from urllib.parse import urlparse
+import ssl
+import certifi
import dj_database_url
from urllib.parse import urlparse
@@ -9,7 +11,6 @@
from sentry_sdk.integrations.redis import RedisIntegration
from .common import * # noqa
-
# Database
DEBUG = True
DATABASES = {
@@ -197,3 +198,9 @@
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
+
+redis_url = os.environ.get("REDIS_URL")
+broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
+
+CELERY_RESULT_BACKEND = broker_url
+CELERY_BROKER_URL = broker_url
\ No newline at end of file
diff --git a/apiserver/plane/utils/importers/jira.py b/apiserver/plane/utils/importers/jira.py
index a5888e2ecea..b427ba14f11 100644
--- a/apiserver/plane/utils/importers/jira.py
+++ b/apiserver/plane/utils/importers/jira.py
@@ -3,33 +3,33 @@
from sentry_sdk import capture_exception
-def jira_project_issue_summary(email, api_token, project_name, hostname):
+def jira_project_issue_summary(email, api_token, project_key, hostname):
try:
auth = HTTPBasicAuth(email, api_token)
headers = {"Accept": "application/json"}
- issue_url = f"https://{hostname}/rest/api/3/search?jql=project={project_name} AND issuetype=Story"
+ issue_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype=Story"
issue_response = requests.request(
"GET", issue_url, headers=headers, auth=auth
).json()["total"]
- module_url = f"https://{hostname}/rest/api/3/search?jql=project={project_name} AND issuetype=Epic"
+ module_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype=Epic"
module_response = requests.request(
"GET", module_url, headers=headers, auth=auth
).json()["total"]
- status_url = f"https://{hostname}/rest/api/3/status/?jql=project={project_name}"
+ status_url = f"https://{hostname}/rest/api/3/status/?jql=project={project_key}"
status_response = requests.request(
"GET", status_url, headers=headers, auth=auth
).json()
- labels_url = f"https://{hostname}/rest/api/3/label/?jql=project={project_name}"
+ labels_url = f"https://{hostname}/rest/api/3/label/?jql=project={project_key}"
labels_response = requests.request(
"GET", labels_url, headers=headers, auth=auth
).json()["total"]
users_url = (
- f"https://{hostname}/rest/api/3/users/search?jql=project={project_name}"
+ f"https://{hostname}/rest/api/3/users/search?jql=project={project_key}"
)
users_response = requests.request(
"GET", users_url, headers=headers, auth=auth
diff --git a/apiserver/plane/utils/issue_search.py b/apiserver/plane/utils/issue_search.py
new file mode 100644
index 00000000000..93b0df6da3d
--- /dev/null
+++ b/apiserver/plane/utils/issue_search.py
@@ -0,0 +1,23 @@
+# Python imports
+import re
+
+# Django imports
+from django.db.models import Q
+
+# Module imports
+from plane.db.models import Issue
+
+
+def search_issues(query):
+ fields = ["name", "sequence_id"]
+ q = Q()
+ for field in fields:
+ if field == "sequence_id":
+ sequences = re.findall(r"\d+\.\d+|\d+", query)
+ for sequence_id in sequences:
+ q |= Q(**{"sequence_id": sequence_id})
+ else:
+ q |= Q(**{f"{field}__icontains": query})
+ return Issue.objects.filter(
+ q,
+ ).distinct()
diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt
index a1e6c0b71f8..e3e58450cd3 100644
--- a/apiserver/requirements/base.txt
+++ b/apiserver/requirements/base.txt
@@ -23,9 +23,9 @@ django-guardian==2.4.0
dj_rest_auth==2.2.5
google-auth==2.16.0
google-api-python-client==2.75.0
-django-rq==2.6.0
django-redis==5.2.0
uvicorn==0.20.0
channels==4.0.0
openai==0.27.2
-slack-sdk==3.20.2
\ No newline at end of file
+slack-sdk==3.20.2
+celery==5.2.7
\ No newline at end of file
diff --git a/apiserver/runtime.txt b/apiserver/runtime.txt
index 2d4e05157e1..a35d6cba1e5 100644
--- a/apiserver/runtime.txt
+++ b/apiserver/runtime.txt
@@ -1 +1 @@
-python-3.11.2
\ No newline at end of file
+python-3.11.3
\ No newline at end of file
diff --git a/apiserver/templates/emails/auth/user_welcome_email.html b/apiserver/templates/emails/auth/user_welcome_email.html
index 84d64fd8d64..af4e60d99a2 100644
--- a/apiserver/templates/emails/auth/user_welcome_email.html
+++ b/apiserver/templates/emails/auth/user_welcome_email.html
@@ -144,7 +144,7 @@ We have put together some resources to help you get started. Please find them below:
diff --git a/apps/app/components/account/email-code-form.tsx b/apps/app/components/account/email-code-form.tsx
index 9fad9c9693d..5e4c49b1af6 100644
--- a/apps/app/components/account/email-code-form.tsx
+++ b/apps/app/components/account/email-code-form.tsx
@@ -180,7 +180,6 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
) : (
{
diff --git a/apps/app/components/auth-screens/index.ts b/apps/app/components/auth-screens/index.ts
new file mode 100644
index 00000000000..2a0bd442380
--- /dev/null
+++ b/apps/app/components/auth-screens/index.ts
@@ -0,0 +1,3 @@
+export * from "./project";
+export * from "./workspace";
+export * from "./not-authorized-view";
diff --git a/apps/app/components/core/not-authorized-view.tsx b/apps/app/components/auth-screens/not-authorized-view.tsx
similarity index 71%
rename from apps/app/components/core/not-authorized-view.tsx
rename to apps/app/components/auth-screens/not-authorized-view.tsx
index 97f3bddacf8..37c07e8def9 100644
--- a/apps/app/components/core/not-authorized-view.tsx
+++ b/apps/app/components/auth-screens/not-authorized-view.tsx
@@ -7,16 +7,16 @@ import { useRouter } from "next/router";
import DefaultLayout from "layouts/default-layout";
// hooks
import useUser from "hooks/use-user";
-// img
-import ProjectSettingImg from "public/project-setting.svg";
+// images
+import ProjectNotAuthorizedImg from "public/auth/project-not-authorized.svg";
+import WorkspaceNotAuthorizedImg from "public/auth/workspace-not-authorized.svg";
-type TNotAuthorizedViewProps = {
+type Props = {
actionButton?: React.ReactNode;
+ type: "project" | "workspace";
};
-export const NotAuthorizedView: React.FC = (props) => {
- const { actionButton } = props;
-
+export const NotAuthorizedView: React.FC = ({ actionButton, type }) => {
const { user } = useUser();
const { asPath: currentPath } = useRouter();
@@ -29,7 +29,12 @@ export const NotAuthorizedView: React.FC = (props) => {
>
-
+
Oops! You are not authorized to view this page
@@ -37,15 +42,15 @@ export const NotAuthorizedView: React.FC = (props) => {
{user ? (
-
- You have signed in as {user.email}.{" "}
+
+ You have signed in as {user.email}.
Sign in
{" "}
with different account that has access to this page.
) : (
-
+
You need to{" "}
Sign in
diff --git a/apps/app/components/auth-screens/project/index.ts b/apps/app/components/auth-screens/project/index.ts
new file mode 100644
index 00000000000..1fb77e697a5
--- /dev/null
+++ b/apps/app/components/auth-screens/project/index.ts
@@ -0,0 +1 @@
+export * from "./join-project";
diff --git a/apps/app/components/auth-screens/project/join-project.tsx b/apps/app/components/auth-screens/project/join-project.tsx
new file mode 100644
index 00000000000..402fff42bb9
--- /dev/null
+++ b/apps/app/components/auth-screens/project/join-project.tsx
@@ -0,0 +1,68 @@
+import { useState } from "react";
+
+import Image from "next/image";
+import { useRouter } from "next/router";
+
+import { mutate } from "swr";
+
+// services
+import projectService from "services/project.service";
+// ui
+import { PrimaryButton } from "components/ui";
+// icons
+import { AssignmentClipboardIcon } from "components/icons";
+// images
+import JoinProjectImg from "public/auth/project-not-authorized.svg";
+// fetch-keys
+import { USER_PROJECT_VIEW } from "constants/fetch-keys";
+
+export const JoinProject: React.FC = () => {
+ const [isJoiningProject, setIsJoiningProject] = useState(false);
+
+ const router = useRouter();
+ const { workspaceSlug, projectId } = router.query;
+
+ const handleJoin = () => {
+ if (!workspaceSlug || !projectId) return;
+
+ setIsJoiningProject(true);
+ projectService
+ .joinProject(workspaceSlug as string, {
+ project_ids: [projectId as string],
+ })
+ .then(async () => {
+ await mutate(USER_PROJECT_VIEW(workspaceSlug.toString()));
+ setIsJoiningProject(false);
+ })
+ .catch((err) => {
+ console.error(err);
+ setIsJoiningProject(false);
+ });
+ };
+
+ return (
+
+
+
+
+
You are not a member of this project
+
+
+
+ You are not a member of this project, but you can join this project by clicking the button
+ below.
+
+
+
+
+
+ {isJoiningProject ? "Joining..." : "Click to join"}
+
+
+
+ );
+};
diff --git a/apps/app/components/auth-screens/workspace/index.ts b/apps/app/components/auth-screens/workspace/index.ts
new file mode 100644
index 00000000000..82832431223
--- /dev/null
+++ b/apps/app/components/auth-screens/workspace/index.ts
@@ -0,0 +1 @@
+export * from "./not-a-member";
diff --git a/apps/app/components/auth-screens/workspace/not-a-member.tsx b/apps/app/components/auth-screens/workspace/not-a-member.tsx
new file mode 100644
index 00000000000..5427275931b
--- /dev/null
+++ b/apps/app/components/auth-screens/workspace/not-a-member.tsx
@@ -0,0 +1,44 @@
+import Link from "next/link";
+import { useRouter } from "next/router";
+
+// layouts
+import DefaultLayout from "layouts/default-layout";
+// ui
+import { PrimaryButton, SecondaryButton } from "components/ui";
+
+export const NotAWorkspaceMember = () => {
+ const router = useRouter();
+
+ return (
+
+
+
+
+
Not Authorized!
+
+ You{"'"}re not a member of this workspace. Please contact the workspace admin to get
+ an invitation or check your pending invitations.
+
+
+
+
+
+
+ );
+};
diff --git a/apps/app/components/command-palette/command-pallette.tsx b/apps/app/components/command-palette/command-pallette.tsx
index 650ab5a6507..53e9cd5a7d1 100644
--- a/apps/app/components/command-palette/command-pallette.tsx
+++ b/apps/app/components/command-palette/command-pallette.tsx
@@ -177,46 +177,49 @@ export const CommandPalette: React.FC = () => {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
+ const singleShortcutKeys = ["p", "v", "d", "h", "q", "m"];
+ const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
+ if (!key) return;
+ const keyPressed = key.toLowerCase();
if (
!(e.target instanceof HTMLTextAreaElement) &&
!(e.target instanceof HTMLInputElement) &&
!(e.target as Element).classList?.contains("remirror-editor")
) {
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
+ if ((ctrlKey || metaKey) && keyPressed === "k") {
e.preventDefault();
setIsPaletteOpen(true);
- } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
- if (e.altKey) {
+ } else if ((ctrlKey || metaKey) && keyPressed === "c") {
+ if (altKey) {
e.preventDefault();
copyIssueUrlToClipboard();
}
- } else if (e.key.toLowerCase() === "c") {
+ } else if (keyPressed === "c") {
e.preventDefault();
setIsIssueModalOpen(true);
- } else if (e.key.toLowerCase() === "p") {
+ } else if ((ctrlKey || metaKey) && keyPressed === "b") {
e.preventDefault();
- setIsProjectModalOpen(true);
- } else if (e.key.toLowerCase() === "v") {
+ toggleCollapsed();
+ } else if (key === "Delete") {
e.preventDefault();
- setIsCreateViewModalOpen(true);
- } else if (e.key.toLowerCase() === "d") {
+ setIsBulkDeleteIssuesModalOpen(true);
+ } else if (
+ singleShortcutKeys.includes(keyPressed) &&
+ (ctrlKey || metaKey || altKey || shiftKey)
+ ) {
e.preventDefault();
+ } else if (keyPressed === "p") {
+ setIsProjectModalOpen(true);
+ } else if (keyPressed === "v") {
+ setIsCreateViewModalOpen(true);
+ } else if (keyPressed === "d") {
setIsCreateUpdatePageModalOpen(true);
- } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") {
- e.preventDefault();
- toggleCollapsed();
- } else if (e.key.toLowerCase() === "h") {
- e.preventDefault();
+ } else if (keyPressed === "h") {
setIsShortcutsModalOpen(true);
- } else if (e.key.toLowerCase() === "q") {
- e.preventDefault();
+ } else if (keyPressed === "q") {
setIsCreateCycleModalOpen(true);
- } else if (e.key.toLowerCase() === "m") {
- e.preventDefault();
+ } else if (keyPressed === "m") {
setIsCreateModuleModalOpen(true);
- } else if (e.key === "Delete") {
- e.preventDefault();
- setIsBulkDeleteIssuesModalOpen(true);
}
}
},
@@ -297,6 +300,11 @@ export const CommandPalette: React.FC = () => {
setIsCreateViewModalOpen(true);
};
+ const createNewPage = () => {
+ setIsPaletteOpen(false);
+ setIsCreateUpdatePageModalOpen(true);
+ };
+
const createNewModule = () => {
setIsPaletteOpen(false);
setIsCreateModuleModalOpen(true);
@@ -652,7 +660,17 @@ export const CommandPalette: React.FC = () => {
Create new view
- Q
+ V
+
+
+
+
+
+
+
+ Create new page
+
+ D
>
diff --git a/apps/app/components/core/board-view/board-header.tsx b/apps/app/components/core/board-view/board-header.tsx
index b9aab7bb4c8..5eaed3a45e3 100644
--- a/apps/app/components/core/board-view/board-header.tsx
+++ b/apps/app/components/core/board-view/board-header.tsx
@@ -142,7 +142,9 @@ export const BoardHeader: React.FC = ({
>
{getGroupTitle()}
-
+
{groupedByIssues?.[groupTitle].length ?? 0}
diff --git a/apps/app/components/core/board-view/single-board.tsx b/apps/app/components/core/board-view/single-board.tsx
index f408bc38b3f..99ec0029f92 100644
--- a/apps/app/components/core/board-view/single-board.tsx
+++ b/apps/app/components/core/board-view/single-board.tsx
@@ -108,7 +108,9 @@ export const SingleBoard: React.FC = ({
key={issue.id}
draggableId={issue.id}
index={index}
- isDragDisabled={isNotAllowed || selectedGroup === "created_by"}
+ isDragDisabled={
+ isNotAllowed || selectedGroup === "created_by" || selectedGroup === "labels"
+ }
>
{(provided, snapshot) => (
= ({
>(
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params),
(prevData) =>
- handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
+ handleIssuesMutation(
+ formData,
+ groupTitle ?? "",
+ selectedGroup,
+ index,
+ orderBy,
+ prevData
+ ),
false
);
else if (moduleId)
@@ -121,10 +129,17 @@ export const SingleBoardIssue: React.FC = ({
>(
MODULE_ISSUES_WITH_PARAMS(moduleId as string),
(prevData) =>
- handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
+ handleIssuesMutation(
+ formData,
+ groupTitle ?? "",
+ selectedGroup,
+ index,
+ orderBy,
+ prevData
+ ),
false
);
- else
+ else {
mutate<
| {
[key: string]: IIssue[];
@@ -132,10 +147,21 @@ export const SingleBoardIssue: React.FC = ({
| IIssue[]
>(
PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
- (prevData) =>
- handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, prevData),
+ (prevData) => {
+ if (!prevData) return prevData;
+
+ return handleIssuesMutation(
+ formData,
+ groupTitle ?? "",
+ selectedGroup,
+ index,
+ orderBy,
+ prevData
+ );
+ },
false
);
+ }
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
@@ -152,7 +178,18 @@ export const SingleBoardIssue: React.FC = ({
console.log(error);
});
},
- [workspaceSlug, projectId, cycleId, moduleId, issue, groupTitle, index, selectedGroup, params]
+ [
+ workspaceSlug,
+ projectId,
+ cycleId,
+ moduleId,
+ issue,
+ groupTitle,
+ index,
+ selectedGroup,
+ orderBy,
+ params,
+ ]
);
const getStyle = (
@@ -343,6 +380,34 @@ export const SingleBoardIssue: React.FC = ({
selfPositioned
/>
)}
+ {properties.estimate && (
+
+ )}
+ {properties.link && (
+
+
+
+
+ {issue.link_count}
+
+
+
+ )}
+ {properties.attachment_count && (
+
+
+
+
+ {issue.attachment_count}
+
+
+
+ )}
diff --git a/apps/app/components/core/existing-issues-list-modal.tsx b/apps/app/components/core/existing-issues-list-modal.tsx
index 4d9ec59097a..2e255ae1963 100644
--- a/apps/app/components/core/existing-issues-list-modal.tsx
+++ b/apps/app/components/core/existing-issues-list-modal.tsx
@@ -19,7 +19,12 @@ import { LayerDiagonalIcon } from "components/icons";
// types
import { IIssue } from "types";
// fetch-keys
-import { CYCLE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
+import {
+ CYCLE_DETAILS,
+ CYCLE_ISSUES_WITH_PARAMS,
+ MODULE_DETAILS,
+ MODULE_ISSUES_WITH_PARAMS,
+} from "constants/fetch-keys";
type FormInput = {
issues: string[];
@@ -76,8 +81,14 @@ export const ExistingIssuesListModal: React.FC = ({
}
await handleOnSubmit(data);
- if (cycleId) mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
- if (moduleId) mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
+ if (cycleId) {
+ mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
+ mutate(CYCLE_DETAILS(cycleId as string));
+ }
+ if (moduleId) {
+ mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
+ mutate(MODULE_DETAILS(moduleId as string));
+ }
handleClose();
diff --git a/apps/app/components/core/feeds.tsx b/apps/app/components/core/feeds.tsx
index f30c8e5d473..c16fcd10f7d 100644
--- a/apps/app/components/core/feeds.tsx
+++ b/apps/app/components/core/feeds.tsx
@@ -8,6 +8,9 @@ import {
ChartBarIcon,
ChatBubbleBottomCenterTextIcon,
ChatBubbleLeftEllipsisIcon,
+ LinkIcon,
+ PaperClipIcon,
+ PlayIcon,
RectangleGroupIcon,
Squares2X2Icon,
TrashIcon,
@@ -70,6 +73,10 @@ const activityDetails: {
message: "updated the description.",
icon: ,
},
+ estimate_point: {
+ message: "set the estimate point to",
+ icon: ,
+ },
target_date: {
message: "set the due date to",
icon: ,
@@ -82,6 +89,18 @@ const activityDetails: {
message: "deleted the issue.",
icon: ,
},
+ estimate: {
+ message: "updated the estimate",
+ icon: ,
+ },
+ link: {
+ message: "updated the link",
+ icon: ,
+ },
+ attachment: {
+ message: "updated the attachment",
+ icon: ,
+ },
};
export const Feeds: React.FC = ({ activities }) => (
@@ -117,13 +136,20 @@ export const Feeds: React.FC = ({ activities }) => (
: "removed the priority";
} else if (activity.field === "description") {
action = "updated the";
+ } else if (activity.field === "attachment") {
+ action = `${activity.verb} the`;
+ } else if (activity.field === "link") {
+ action = `${activity.verb} the`;
}
// for values that are after the action clause
let value: any = activity.new_value ? activity.new_value : activity.old_value;
if (
activity.verb === "created" &&
activity.field !== "cycles" &&
- activity.field !== "modules"
+ activity.field !== "modules" &&
+ activity.field !== "attachment" &&
+ activity.field !== "link" &&
+ activity.field !== "estimate"
) {
const { workspace_detail, project, issue } = activity;
value = (
@@ -160,6 +186,14 @@ export const Feeds: React.FC = ({ activities }) => (
value = renderShortNumericDateFormat(date as string);
} else if (activity.field === "description") {
value = "description";
+ } else if (activity.field === "attachment") {
+ value = "attachment";
+ } else if (activity.field === "link") {
+ value = "link";
+ } else if (activity.field === "estimate_point") {
+ value = activity.new_value
+ ? activity.new_value + ` Point${parseInt(activity.new_value ?? "", 10) > 1 ? "s" : ""}`
+ : "None";
}
if (activity.field === "comment") {
diff --git a/apps/app/components/core/filter-list.tsx b/apps/app/components/core/filter-list.tsx
index 776a8f8a0d8..08d1e806213 100644
--- a/apps/app/components/core/filter-list.tsx
+++ b/apps/app/components/core/filter-list.tsx
@@ -309,7 +309,19 @@ export const FilterList: React.FC = ({ filters, setFilters }) => {
)}
) : (
- {filters[key as keyof typeof filters]}
+
+ {filters[key as keyof typeof filters]}
+
+
)}
);
@@ -319,6 +331,7 @@ export const FilterList: React.FC = ({ filters, setFilters }) => {
type="button"
onClick={() =>
setFilters({
+ type: null,
state: null,
priority: null,
assignees: null,
diff --git a/apps/app/components/core/image-picker-popover.tsx b/apps/app/components/core/image-picker-popover.tsx
index 13449a37463..a6f2efb72bf 100644
--- a/apps/app/components/core/image-picker-popover.tsx
+++ b/apps/app/components/core/image-picker-popover.tsx
@@ -17,6 +17,10 @@ import { Input, Spinner, PrimaryButton } from "components/ui";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
+const unsplashEnabled =
+ process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" ||
+ process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "1";
+
const tabOptions = [
{
key: "unsplash",
@@ -56,10 +60,12 @@ export const ImagePickerPopover: React.FC = ({ label, value, onChange })
onChange(images[0].urls.regular);
}, [value, onChange, images]);
+ if (!unsplashEnabled) return null;
+
return (
setIsOpen((prev) => !prev)}
>
{label}
@@ -92,13 +98,7 @@ export const ImagePickerPopover: React.FC = ({ label, value, onChange })
-