diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml index 4e672326c..4240c10c5 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 e462b2780..102739e4e 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 35f6e9aa8..30d734913 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 1ba312934..c1ccc28b5 100644 --- a/apiserver/back_migration.py +++ b/apiserver/back_migration.py @@ -3,8 +3,7 @@ import uuid 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 25a947613..9d2da1254 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 e69de29bb..fb989c4e6 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 57bff15c2..2adff8299 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -11,6 +11,7 @@ from .workspace import ( TeamSerializer, WorkSpaceMemberInviteSerializer, WorkspaceLiteSerializer, + WorkspaceThemeSerializer, ) from .project import ( ProjectSerializer, @@ -41,6 +42,7 @@ from .issue import ( IssueStateSerializer, IssueLinkSerializer, IssueLiteSerializer, + IssueAttachmentSerializer, ) from .module import ( @@ -65,3 +67,5 @@ from .integration import ( 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 000000000..0aa4d331e --- /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 28f2153c8..fcc7da6ce 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 c5d53f838..a39128088 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -25,6 +25,7 @@ from plane.db.models import ( Module, ModuleIssue, IssueLink, + IssueAttachment, ) @@ -99,7 +100,7 @@ class IssueCreateSerializer(BaseSerializer): 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 @@ class IssueCreateSerializer(BaseSerializer): 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 @@ class IssueCreateSerializer(BaseSerializer): ], batch_size=10, ) + 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: + if labels is not None and len(labels): IssueLabel.objects.bulk_create( [ IssueLabel( @@ -147,7 +159,7 @@ class IssueCreateSerializer(BaseSerializer): 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 IssueActivitySerializer(BaseSerializer): 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 IssuePropertySerializer(BaseSerializer): 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 @@ class IssueLinkSerializer(BaseSerializer): 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 021bcfb72..076228ae0 100644 --- a/apiserver/plane/api/serializers/view.py +++ b/apiserver/plane/api/serializers/view.py @@ -25,22 +25,18 @@ class IssueViewSerializer(BaseSerializer): 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 7b3cb1896..4f4d13f76 100644 --- a/apiserver/plane/api/serializers/workspace.py +++ b/apiserver/plane/api/serializers/workspace.py @@ -5,8 +5,15 @@ from rest_framework import serializers 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 WorkspaceLiteSerializer(BaseSerializer): "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 d408be37e..0e27ce665 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -42,6 +42,7 @@ from plane.api.views import ( UserActivityGraphEndpoint, UserIssueCompletedGraphEndpoint, UserWorkspaceDashboardEndpoint, + WorkspaceThemeViewSet, ## End Workspaces # File Assets FileAssetEndpoint, @@ -74,10 +75,17 @@ from plane.api.views import ( 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 @@ from plane.api.views import ( ## End importer # Search GlobalSearchEndpoint, + IssueSearchEndpoint, ## End Search # Gpt GPTIntegrationEndpoint, @@ -342,6 +351,27 @@ urlpatterns = [ 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 @@ urlpatterns = [ 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 @@ urlpatterns = [ ), 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 @@ urlpatterns = [ 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 @@ urlpatterns = [ 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 b6171d68b..82eb49e44 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -40,6 +40,7 @@ from .workspace import ( UserActivityGraphEndpoint, UserIssueCompletedGraphEndpoint, UserWorkspaceDashboardEndpoint, + WorkspaceThemeViewSet, ) from .state import StateViewSet from .shortcut import ShortCutViewSet @@ -69,6 +70,7 @@ from .issue import ( SubIssuesEndpoint, IssueLinkViewSet, BulkCreateIssueLabelsEndpoint, + IssueAttachmentEndpoint, ) from .auth_extended import ( @@ -125,7 +127,14 @@ from .page import ( 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 abdee4812..98c9f9caf 100644 --- a/apiserver/plane/api/views/asset.py +++ b/apiserver/plane/api/views/asset.py @@ -65,6 +65,8 @@ class FileAssetEndpoint(BaseAPIView): 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 73edb7d1e..df95a1b7a 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -28,6 +28,8 @@ from plane.db.models import ( 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 @@ class CycleIssueViewSet(BaseViewSet): .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 @@ class CycleIssueViewSet(BaseViewSet): # 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 @@ class CycleDateCheckEndpoint(BaseAPIView): 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 000000000..96d0ed1a4 --- /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 dd52d2dd2..a51af9c22 100644 --- a/apiserver/plane/api/views/importer.py +++ b/apiserver/plane/api/views/importer.py @@ -65,29 +65,35 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView): ) 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) - ): - return Response( - { - "error": "Project name, Project key, API token, Cloud hostname and email are requied" - }, - status=status.HTTP_400_BAD_REQUEST, - ) + # 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", + } - return Response( - jira_project_issue_summary( - email, api_token, project_name, cloud_hostname - ), - status=status.HTTP_200_OK, + 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( + response, + status=status.HTTP_200_OK, + ) return Response( {"error": "Service not supported yet"}, status=status.HTTP_400_BAD_REQUEST, @@ -213,8 +219,10 @@ class ImportServiceEndpoint(BaseAPIView): 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 @@ class ImportServiceEndpoint(BaseAPIView): 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 d22c65092..1f604d271 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -12,6 +12,7 @@ from django.views.decorators.gzip import gzip_page # 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 @@ from plane.api.serializers import ( IssueFlatSerializer, IssueLinkSerializer, IssueLiteSerializer, + IssueAttachmentSerializer, ) from plane.api.permissions import ( ProjectEntityPermission, @@ -43,6 +45,7 @@ from plane.db.models import ( IssueProperty, Label, IssueLink, + IssueAttachment, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results @@ -82,16 +85,14 @@ class IssueViewSet(BaseViewSet): ) 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 @@ class IssueViewSet(BaseViewSet): ) 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 @@ class IssueViewSet(BaseViewSet): .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 @@ class IssueViewSet(BaseViewSet): # 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 @@ class UserWorkSpaceIssues(BaseAPIView): .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 @@ class IssueCommentViewSet(BaseViewSet): 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 @@ class IssueCommentViewSet(BaseViewSet): ) 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 @@ class IssueCommentViewSet(BaseViewSet): ) 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 @@ class IssueLinkViewSet(BaseViewSet): 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 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView): {"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 3cdb54f70..0abf47c8b 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -31,6 +31,8 @@ from plane.db.models import ( 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 @@ class ModuleIssueViewSet(BaseViewSet): .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 @@ class ModuleIssueViewSet(BaseViewSet): # 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 ba75eac91..f73a3c9c8 100644 --- a/apiserver/plane/api/views/search.py +++ b/apiserver/plane/api/views/search.py @@ -12,6 +12,7 @@ from sentry_sdk import capture_exception # 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 @@ class GlobalSearchEndpoint(BaseAPIView): 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 @@ class GlobalSearchEndpoint(BaseAPIView): 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 @@ class GlobalSearchEndpoint(BaseAPIView): 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 @@ class GlobalSearchEndpoint(BaseAPIView): 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 @@ class GlobalSearchEndpoint(BaseAPIView): 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 @@ class GlobalSearchEndpoint(BaseAPIView): 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 @@ class GlobalSearchEndpoint(BaseAPIView): {"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 a1c18f995..915ade2fc 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -36,6 +36,7 @@ from plane.api.serializers import ( WorkSpaceMemberInviteSerializer, UserLiteSerializer, ProjectMemberSerializer, + WorkspaceThemeSerializer, ) from plane.api.views.base import BaseAPIView from . import BaseViewSet @@ -48,6 +49,7 @@ from plane.db.models import ( ProjectMember, IssueActivity, Issue, + WorkspaceTheme, ) from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission from plane.bgtasks.workspace_invitation_task import workspace_invitation @@ -752,3 +754,35 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView): {"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 e69de29bb..000000000 diff --git a/apiserver/plane/bgtasks/email_verification_task.py b/apiserver/plane/bgtasks/email_verification_task.py index cf233c531..ee4680e53 100644 --- a/apiserver/plane/bgtasks/email_verification_task.py +++ b/apiserver/plane/bgtasks/email_verification_task.py @@ -4,14 +4,16 @@ from django.template.loader import render_to_string 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 7d169e8cf..4598e5f2f 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -4,14 +4,14 @@ from django.template.loader import render_to_string 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 f5dadf322..fba43f6e4 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -11,7 +11,7 @@ from django.core.serializers.json import DjangoJSONEncoder 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 plane.db.models import ( 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, - ) + # 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" - ] - ) + 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, + ) - # 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, - ) + 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 efc7f196e..c4fde9646 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -7,7 +7,7 @@ from django.conf import settings 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,21 +772,6 @@ def update_comment_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 delete_comment_activity( requested_data, current_instance, issue_id, project, actor, issue_activities ): @@ -736,28 +788,119 @@ 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} 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="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 89239e87d..89554dcca 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.template.loader import render_to_string 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 681438851..18e539970 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -4,18 +4,16 @@ from django.template.loader import render_to_string 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 0ed807171..c6e69689b 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -5,7 +5,7 @@ from django.utils.html import strip_tags 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 slack_sdk.errors import SlackApiError 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 000000000..1fbbdd732 --- /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 000000000..6f74fa499 --- /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 000000000..8d344cf34 --- /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 000000000..bb0b67b92 --- /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 8a3021741..b6ffe428c 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -8,6 +8,7 @@ from .workspace import ( Team, WorkspaceMemberInvite, TeamMember, + WorkspaceTheme, ) from .project import ( @@ -32,6 +33,7 @@ from .issue import ( IssueBlocker, IssueLink, IssueSequence, + IssueAttachment, ) from .asset import FileAsset @@ -61,4 +63,6 @@ from .integration import ( 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 000000000..f163a1407 --- /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 655a03e75..fed946a61 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.conf import settings 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 @@ class IssueLink(ProjectBaseModel): 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 b3c8f669a..04435cadf 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 8a30981f3..334ec3e13 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 5715bb304..b00d53013 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -36,7 +36,6 @@ class Workspace(BaseModel): ordering = ("-created_at",) - class WorkspaceMember(BaseModel): workspace = models.ForeignKey( "db.Workspace", on_delete=models.CASCADE, related_name="workspace_member" @@ -111,7 +110,6 @@ class Team(BaseModel): class TeamMember(BaseModel): - workspace = models.ForeignKey( Workspace, on_delete=models.CASCADE, related_name="team_member" ) @@ -129,3 +127,24 @@ class TeamMember(BaseModel): 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 73c3c4be5..c144eeb0b 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -35,7 +35,6 @@ INSTALLED_APPS = [ "rest_framework_simplejwt.token_blacklist", "corsheaders", "taggit", - "django_rq", ] MIDDLEWARE = [ @@ -208,3 +207,7 @@ SIMPLE_JWT = { "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 bf161568b..c3bf65588 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -59,16 +59,8 @@ if os.environ.get("SENTRY_DSN", False): 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 @@ GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003") 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 5569e1c09..8f8453aff 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 @@ GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003") 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 b32cf8c80..4e906c4a1 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 9015ce03f..384116ba3 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.django import DjangoIntegration from sentry_sdk.integrations.redis import RedisIntegration from .common import * # noqa - # Database DEBUG = True DATABASES = { @@ -197,3 +198,9 @@ GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003") 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 a5888e2ec..b427ba14f 100644 --- a/apiserver/plane/utils/importers/jira.py +++ b/apiserver/plane/utils/importers/jira.py @@ -3,33 +3,33 @@ from requests.auth import HTTPBasicAuth 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 000000000..93b0df6da --- /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 a1e6c0b71..e3e58450c 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 2d4e05157..a35d6cba1 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 84d64fd8d..af4e60d99 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 9fad9c969..5e4c49b1a 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 000000000..2a0bd4423 --- /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 97f3bddac..37c07e8de 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) => { >
- ProjectSettingImg + ProjectSettingImg

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 000000000..1fb77e697 --- /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 000000000..402fff42b --- /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 ( +

+
+ JoinProject +
+

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 000000000..828324312 --- /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 000000000..542727593 --- /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 650ab5a65..53e9cd5a7 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") { - e.preventDefault(); - setIsProjectModalOpen(true); - } else if (e.key.toLowerCase() === "v") { - e.preventDefault(); - setIsCreateViewModalOpen(true); - } else if (e.key.toLowerCase() === "d") { - e.preventDefault(); - setIsCreateUpdatePageModalOpen(true); - } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") { + } else if ((ctrlKey || metaKey) && keyPressed === "b") { e.preventDefault(); toggleCollapsed(); - } else if (e.key.toLowerCase() === "h") { - e.preventDefault(); - setIsShortcutsModalOpen(true); - } else if (e.key.toLowerCase() === "q") { - e.preventDefault(); - setIsCreateCycleModalOpen(true); - } else if (e.key.toLowerCase() === "m") { - e.preventDefault(); - setIsCreateModuleModalOpen(true); - } else if (e.key === "Delete") { + } else if (key === "Delete") { e.preventDefault(); 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 (keyPressed === "h") { + setIsShortcutsModalOpen(true); + } else if (keyPressed === "q") { + setIsCreateCycleModalOpen(true); + } else if (keyPressed === "m") { + setIsCreateModuleModalOpen(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 b9aab7bb4..5eaed3a45 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 f408bc38b..99ec0029f 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 4d9ec5909..2e255ae19 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 f30c8e5d4..c16fcd10f 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: