diff --git a/apiserver/.env.example b/apiserver/.env.example index 15056f072..8a7c76ffa 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -3,15 +3,19 @@ DJANGO_SETTINGS_MODULE="plane.settings.production" DATABASE_URL=postgres://plane:xyzzyspoon@db:5432/plane # Cache REDIS_URL=redis://redis:6379/ -# SMPT +# SMTP EMAIL_HOST="" EMAIL_HOST_USER="" EMAIL_HOST_PASSWORD="" -# AWS +EMAIL_PORT="587" +EMAIL_USE_TLS="1" +EMAIL_FROM="Team Plane " +# AWS AWS_REGION="" AWS_ACCESS_KEY_ID="" AWS_SECRET_ACCESS_KEY="" AWS_S3_BUCKET_NAME="" +AWS_S3_ENDPOINT_URL="" # FE WEB_URL="localhost/" # OAUTH @@ -21,4 +25,4 @@ DISABLE_COLLECTSTATIC=1 DOCKERIZED=1 # GPT Envs OPENAI_API_KEY=0 -GPT_ENGINE=0 \ No newline at end of file +GPT_ENGINE=0 diff --git a/apiserver/back_migration.py b/apiserver/back_migration.py index c1ccc28b5..0f272755f 100644 --- a/apiserver/back_migration.py +++ b/apiserver/back_migration.py @@ -3,7 +3,15 @@ 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, Label +from plane.db.models import ( + Issue, + IssueComment, + User, + Project, + ProjectMember, + Label, + Integration, +) # Update description and description html values for old descriptions @@ -174,3 +182,29 @@ def update_label_color(): except Exception as e: print(e) print("Failed") + + +def create_slack_integration(): + try: + _ = Integration.objects.create(provider="slack", network=2, title="Slack") + print("Success") + except Exception as e: + print(e) + print("Failed") + + +def update_integration_verified(): + try: + integrations = Integration.objects.all() + updated_integrations = [] + for integration in integrations: + integration.verified = True + updated_integrations.append(integration) + + Integration.objects.bulk_update( + updated_integrations, ["verified"], batch_size=10 + ) + print("Sucess") + except Exception as e: + print(e) + print("Failed") diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 2adff8299..79014c53d 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -62,10 +62,11 @@ from .integration import ( GithubRepositorySerializer, GithubRepositorySyncSerializer, GithubCommentSyncSerializer, + SlackProjectSyncSerializer, ) from .importer import ImporterSerializer from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer -from .estimate import EstimateSerializer, EstimatePointSerializer +from .estimate import EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer diff --git a/apiserver/plane/api/serializers/estimate.py b/apiserver/plane/api/serializers/estimate.py index 0aa4d331e..360275562 100644 --- a/apiserver/plane/api/serializers/estimate.py +++ b/apiserver/plane/api/serializers/estimate.py @@ -23,3 +23,16 @@ class EstimatePointSerializer(BaseSerializer): "workspace", "project", ] + + +class EstimateReadSerializer(BaseSerializer): + points = EstimatePointSerializer(read_only=True, many=True) + + class Meta: + model = Estimate + fields = "__all__" + read_only_fields = [ + "points", + "name", + "description", + ] diff --git a/apiserver/plane/api/serializers/integration/__init__.py b/apiserver/plane/api/serializers/integration/__init__.py index 8aea68bd6..963fc295e 100644 --- a/apiserver/plane/api/serializers/integration/__init__.py +++ b/apiserver/plane/api/serializers/integration/__init__.py @@ -5,3 +5,4 @@ from .github import ( GithubIssueSyncSerializer, GithubCommentSyncSerializer, ) +from .slack import SlackProjectSyncSerializer \ No newline at end of file diff --git a/apiserver/plane/api/serializers/integration/slack.py b/apiserver/plane/api/serializers/integration/slack.py new file mode 100644 index 000000000..f535a64de --- /dev/null +++ b/apiserver/plane/api/serializers/integration/slack.py @@ -0,0 +1,14 @@ +# Module imports +from plane.api.serializers import BaseSerializer +from plane.db.models import SlackProjectSync + + +class SlackProjectSyncSerializer(BaseSerializer): + class Meta: + model = SlackProjectSync + fields = "__all__" + read_only_fields = [ + "project", + "workspace", + "workspace_integration", + ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 0e27ce665..a88744b4a 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -81,8 +81,6 @@ from plane.api.views import ( StateViewSet, ## End States # Estimates - EstimateViewSet, - EstimatePointViewSet, ProjectEstimatePointEndpoint, BulkEstimatePointEndpoint, ## End Estimates @@ -133,6 +131,7 @@ from plane.api.views import ( GithubIssueSyncViewSet, GithubCommentSyncViewSet, BulkCreateGithubIssueSyncEndpoint, + SlackProjectSyncViewSet, ## End Integrations # Importer ServiceIssueImportSummaryEndpoint, @@ -146,6 +145,9 @@ from plane.api.views import ( # Gpt GPTIntegrationEndpoint, ## End Gpt + # Release Notes + ReleaseNotesEndpoint, + ## End Release Notes ) @@ -507,62 +509,34 @@ 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", - ), + # Estimates path( "workspaces//projects//project-estimates/", ProjectEstimatePointEndpoint.as_view(), name="project-estimate-points", ), path( - "workspaces//projects//estimates//bulk-estimate-points/", - BulkEstimatePointEndpoint.as_view(), + "workspaces//projects//estimates/", + BulkEstimatePointEndpoint.as_view( + { + "get": "list", + "post": "create", + } + ), name="bulk-create-estimate-points", ), - # End States ## + path( + "workspaces//projects//estimates//", + BulkEstimatePointEndpoint.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="bulk-create-estimate-points", + ), + # End Estimates ## # Shortcuts path( "workspaces//projects//shortcuts/", @@ -1237,6 +1211,26 @@ urlpatterns = [ ), ), ## End Github Integrations + # Slack Integration + path( + "workspaces//projects//workspace-integrations//project-slack-sync/", + SlackProjectSyncViewSet.as_view( + { + "post": "create", + "get": "list", + } + ), + ), + path( + "workspaces//projects//workspace-integrations//project-slack-sync//", + SlackProjectSyncViewSet.as_view( + { + "delete": "destroy", + "get": "retrieve", + } + ), + ), + ## End Slack Integration ## End Integrations # Importer path( @@ -1284,4 +1278,11 @@ urlpatterns = [ name="importer", ), ## End Gpt + # Release Notes + path( + "release-notes/", + ReleaseNotesEndpoint.as_view(), + name="release-notes", + ), + ## End Release Notes ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 82eb49e44..536fd83bf 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -106,6 +106,7 @@ from .integration import ( GithubCommentSyncViewSet, GithubRepositoriesEndpoint, BulkCreateGithubIssueSyncEndpoint, + SlackProjectSyncViewSet, ) from .importer import ( @@ -133,8 +134,9 @@ from .search import GlobalSearchEndpoint, IssueSearchEndpoint from .gpt import GPTIntegrationEndpoint from .estimate import ( - EstimateViewSet, - EstimatePointViewSet, ProjectEstimatePointEndpoint, BulkEstimatePointEndpoint, ) + + +from .release import ReleaseNotesEndpoint diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index a4b9ac584..3c260e03b 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -10,7 +10,7 @@ from rest_framework.views import APIView from rest_framework.filters import SearchFilter from rest_framework.permissions import IsAuthenticated from rest_framework.exceptions import NotFound - +from sentry_sdk import capture_exception from django_filters.rest_framework import DjangoFilterBackend # Module imports @@ -39,7 +39,7 @@ class BaseViewSet(ModelViewSet, BasePaginator): try: return self.model.objects.all() except Exception as e: - print(e) + capture_exception(e) raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) def dispatch(self, request, *args, **kwargs): diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index df95a1b7a..9265aca00 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -48,6 +48,28 @@ class CycleViewSet(BaseViewSet): project_id=self.kwargs.get("project_id"), owned_by=self.request.user ) + def perform_destroy(self, instance): + cycle_issues = list( + CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( + "issue", flat=True + ) + ) + issue_activity.delay( + type="cycle.activity.deleted", + requested_data=json.dumps( + { + "cycle_id": str(self.kwargs.get("pk")), + "issues": [str(issue_id) for issue_id in cycle_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=None, + ) + + return super().perform_destroy(instance) + def get_queryset(self): subquery = CycleFavorite.objects.filter( user=self.request.user, @@ -181,6 +203,22 @@ class CycleIssueViewSet(BaseViewSet): cycle_id=self.kwargs.get("cycle_id"), ) + def perform_destroy(self, instance): + issue_activity.delay( + type="cycle.activity.deleted", + requested_data=json.dumps( + { + "cycle_id": str(self.kwargs.get("cycle_id")), + "issues": [str(instance.issue_id)], + } + ), + 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=None, + ) + return super().perform_destroy(instance) + def get_queryset(self): return self.filter_queryset( super() @@ -286,9 +324,9 @@ class CycleIssueViewSet(BaseViewSet): # Get all CycleIssues already created cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues)) - records_to_update = [] update_cycle_issue_activity = [] record_to_create = [] + records_to_update = [] for issue in issues: cycle_issue = [ @@ -333,7 +371,7 @@ class CycleIssueViewSet(BaseViewSet): # Capture Issue Activity issue_activity.delay( - type="issue.activity.updated", + type="cycle.activity.created", requested_data=json.dumps({"cycles_list": issues}), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("pk", None)), diff --git a/apiserver/plane/api/views/estimate.py b/apiserver/plane/api/views/estimate.py index 96d0ed1a4..e878ccafc 100644 --- a/apiserver/plane/api/views/estimate.py +++ b/apiserver/plane/api/views/estimate.py @@ -10,110 +10,11 @@ from sentry_sdk import capture_exception 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, - ) +from plane.api.serializers import ( + EstimateSerializer, + EstimatePointSerializer, + EstimateReadSerializer, +) class ProjectEstimatePointEndpoint(BaseAPIView): @@ -141,17 +42,35 @@ class ProjectEstimatePointEndpoint(BaseAPIView): ) -class BulkEstimatePointEndpoint(BaseAPIView): +class BulkEstimatePointEndpoint(BaseViewSet): permission_classes = [ ProjectEntityPermission, ] + model = Estimate + serializer_class = EstimateSerializer - def post(self, request, slug, project_id, estimate_id): + def list(self, request, slug, project_id): try: - estimate = Estimate.objects.get( - pk=estimate_id, workspace__slug=slug, project=project_id + estimates = Estimate.objects.filter( + workspace__slug=slug, project_id=project_id + ).prefetch_related("points") + serializer = EstimateReadSerializer(estimates, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except Exception as e: + print(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, ) + def create(self, request, slug, project_id): + try: + if not request.data.get("estimate", False): + return Response( + {"error": "Estimate is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + estimate_points = request.data.get("estimate_points", []) if not len(estimate_points) or len(estimate_points) > 8: @@ -160,6 +79,18 @@ class BulkEstimatePointEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) + estimate_serializer = EstimateSerializer(data=request.data.get("estimate")) + if not estimate_serializer.is_valid(): + return Response( + estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + try: + estimate = estimate_serializer.save(project_id=project_id) + except IntegrityError: + return Response( + {"errror": "Estimate with the name already exists"}, + status=status.HTTP_400_BAD_REQUEST, + ) estimate_points = EstimatePoint.objects.bulk_create( [ EstimatePoint( @@ -178,9 +109,17 @@ class BulkEstimatePointEndpoint(BaseAPIView): ignore_conflicts=True, ) - serializer = EstimatePointSerializer(estimate_points, many=True) + estimate_point_serializer = EstimatePointSerializer( + estimate_points, many=True + ) - return Response(serializer.data, status=status.HTTP_200_OK) + return Response( + { + "estimate": estimate_serializer.data, + "estimate_points": estimate_point_serializer.data, + }, + status=status.HTTP_200_OK, + ) except Estimate.DoesNotExist: return Response( {"error": "Estimate does not exist"}, @@ -193,14 +132,58 @@ class BulkEstimatePointEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - def patch(self, request, slug, project_id, estimate_id): + def retrieve(self, request, slug, project_id, estimate_id): try: + estimate = Estimate.objects.get( + pk=estimate_id, workspace__slug=slug, project_id=project_id + ) + serializer = EstimateReadSerializer(estimate) + 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 partial_update(self, request, slug, project_id, estimate_id): + try: + if not request.data.get("estimate", False): + return Response( + {"error": "Estimate is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not len(request.data.get("estimate_points", [])): return Response( {"error": "Estimate points are required"}, status=status.HTTP_400_BAD_REQUEST, ) + estimate = Estimate.objects.get(pk=estimate_id) + + estimate_serializer = EstimateSerializer( + estimate, data=request.data.get("estimate"), partial=True + ) + if not estimate_serializer.is_valid(): + return Response( + estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + try: + estimate = estimate_serializer.save() + except IntegrityError: + return Response( + {"errror": "Estimate with the name already exists"}, + status=status.HTTP_400_BAD_REQUEST, + ) + estimate_points_data = request.data.get("estimate_points", []) estimate_points = EstimatePoint.objects.filter( @@ -212,7 +195,6 @@ class BulkEstimatePointEndpoint(BaseAPIView): estimate_id=estimate_id, ) - print(estimate_points) updated_estimate_points = [] for estimate_point in estimate_points: # Find the data for that estimate point @@ -221,24 +203,50 @@ class BulkEstimatePointEndpoint(BaseAPIView): 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 + try: + EstimatePoint.objects.bulk_update( + updated_estimate_points, ["value"], batch_size=10 + ) + except IntegrityError as e: + return Response( + {"error": "Values need to be unique for each key"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + estimate_point_serializer = EstimatePointSerializer(estimate_points, many=True) + return Response( + { + "estimate": estimate_serializer.data, + "estimate_points": estimate_point_serializer.data, + }, + status=status.HTTP_200_OK, ) - 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) + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def destroy(self, request, slug, project_id, estimate_id): + try: + estimate = Estimate.objects.get( + pk=estimate_id, workspace__slug=slug, project_id=project_id + ) + estimate.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, diff --git a/apiserver/plane/api/views/importer.py b/apiserver/plane/api/views/importer.py index a51af9c22..b9a7fe0c5 100644 --- a/apiserver/plane/api/views/importer.py +++ b/apiserver/plane/api/views/importer.py @@ -28,6 +28,7 @@ from plane.db.models import ( Module, ModuleLink, ModuleIssue, + Label, ) from plane.api.serializers import ( ImporterSerializer, @@ -104,7 +105,7 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) except Exception as e: - print(e) + capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, @@ -235,9 +236,20 @@ class ImportServiceEndpoint(BaseAPIView): def delete(self, request, slug, service, pk): try: - importer = Importer.objects.filter( + importer = Importer.objects.get( pk=pk, service=service, workspace__slug=slug ) + # Delete all imported Issues + imported_issues = importer.imported_data.get("issues", []) + Issue.objects.filter(id__in=imported_issues).delete() + + # Delete all imported Labels + imported_labels = importer.imported_data.get("labels", []) + Label.objects.filter(id__in=imported_labels).delete() + + if importer.service == "jira": + imported_modules = importer.imported_data.get("modules", []) + Module.objects.filter(id__in=imported_modules).delete() importer.delete() return Response(status=status.HTTP_204_NO_CONTENT) except Exception as e: @@ -247,6 +259,27 @@ class ImportServiceEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) + def patch(self, request, slug, service, pk): + try: + importer = Importer.objects.get( + pk=pk, service=service, workspace__slug=slug + ) + serializer = ImporterSerializer(importer, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except Importer.DoesNotExist: + return Response( + {"error": "Importer Does not exists"}, status=status.HTTP_404_NOT_FOUND + ) + 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): @@ -487,48 +520,59 @@ class BulkImportModulesEndpoint(BaseAPIView): ignore_conflicts=True, ) - _ = ModuleLink.objects.bulk_create( - [ - ModuleLink( - module=module, - url=module_data.get("link", {}).get("url", "https://plane.so"), - title=module_data.get("link", {}).get( - "title", "Original Issue" - ), - project_id=project_id, - workspace_id=project.workspace_id, - created_by=request.user, - updated_by=request.user, - ) - for module, module_data in zip(modules, modules_data) - ], - batch_size=100, - ignore_conflicts=True, - ) + modules = Module.objects.filter(id__in=[module.id for module in modules]) - bulk_module_issues = [] - for module, module_data in zip(modules, modules_data): - module_issues_list = module_data.get("module_issues_list", []) - bulk_module_issues = bulk_module_issues + [ - ModuleIssue( - issue_id=issue, - module=module, - project_id=project_id, - workspace_id=project.workspace_id, - created_by=request.user, - updated_by=request.user, - ) - for issue in module_issues_list - ] + if len(modules) == len(modules_data): + _ = ModuleLink.objects.bulk_create( + [ + ModuleLink( + module=module, + url=module_data.get("link", {}).get( + "url", "https://plane.so" + ), + title=module_data.get("link", {}).get( + "title", "Original Issue" + ), + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for module, module_data in zip(modules, modules_data) + ], + batch_size=100, + ignore_conflicts=True, + ) - _ = ModuleIssue.objects.bulk_create( - bulk_module_issues, batch_size=100, ignore_conflicts=True - ) + bulk_module_issues = [] + for module, module_data in zip(modules, modules_data): + module_issues_list = module_data.get("module_issues_list", []) + bulk_module_issues = bulk_module_issues + [ + ModuleIssue( + issue_id=issue, + module=module, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for issue in module_issues_list + ] - serializer = ModuleSerializer(modules, many=True) - return Response( - {"modules": serializer.data}, status=status.HTTP_201_CREATED - ) + _ = ModuleIssue.objects.bulk_create( + bulk_module_issues, batch_size=100, ignore_conflicts=True + ) + + serializer = ModuleSerializer(modules, many=True) + return Response( + {"modules": serializer.data}, status=status.HTTP_201_CREATED + ) + + else: + return Response( + {"message": "Modules created but issues could not be imported"}, + status=status.HTTP_200_OK, + ) except Project.DoesNotExist: return Response( {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND diff --git a/apiserver/plane/api/views/integration/__init__.py b/apiserver/plane/api/views/integration/__init__.py index 67dd370d9..ea20d96ea 100644 --- a/apiserver/plane/api/views/integration/__init__.py +++ b/apiserver/plane/api/views/integration/__init__.py @@ -6,3 +6,4 @@ from .github import ( GithubCommentSyncViewSet, GithubRepositoriesEndpoint, ) +from .slack import SlackProjectSyncViewSet diff --git a/apiserver/plane/api/views/integration/base.py b/apiserver/plane/api/views/integration/base.py index 8312afa01..5213baf63 100644 --- a/apiserver/plane/api/views/integration/base.py +++ b/apiserver/plane/api/views/integration/base.py @@ -27,6 +27,7 @@ from plane.utils.integrations.github import ( ) from plane.api.permissions import WorkSpaceAdminPermission + class IntegrationViewSet(BaseViewSet): serializer_class = IntegrationSerializer model = Integration @@ -101,7 +102,6 @@ class WorkspaceIntegrationViewSet(BaseViewSet): WorkSpaceAdminPermission, ] - def get_queryset(self): return ( super() @@ -112,21 +112,30 @@ class WorkspaceIntegrationViewSet(BaseViewSet): def create(self, request, slug, provider): try: - installation_id = request.data.get("installation_id", None) - - if not installation_id: - return Response( - {"error": "Installation ID is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - workspace = Workspace.objects.get(slug=slug) integration = Integration.objects.get(provider=provider) config = {} if provider == "github": + installation_id = request.data.get("installation_id", None) + if not installation_id: + return Response( + {"error": "Installation ID is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) metadata = get_github_metadata(installation_id) config = {"installation_id": installation_id} + if provider == "slack": + metadata = request.data.get("metadata", {}) + access_token = metadata.get("access_token", False) + team_id = metadata.get("team", {}).get("id", False) + if not metadata or not access_token or not team_id: + return Response( + {"error": "Access token and team id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + config = {"team_id": team_id, "access_token": access_token} + # Create a bot user bot_user = User.objects.create( email=f"{uuid.uuid4().hex}@plane.so", diff --git a/apiserver/plane/api/views/integration/slack.py b/apiserver/plane/api/views/integration/slack.py new file mode 100644 index 000000000..06e2dfe39 --- /dev/null +++ b/apiserver/plane/api/views/integration/slack.py @@ -0,0 +1,59 @@ +# Django import +from django.db import IntegrityError + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from sentry_sdk import capture_exception + +# Module imports +from plane.api.views import BaseViewSet, BaseAPIView +from plane.db.models import SlackProjectSync, WorkspaceIntegration, ProjectMember +from plane.api.serializers import SlackProjectSyncSerializer +from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission + + +class SlackProjectSyncViewSet(BaseViewSet): + permission_classes = [ + ProjectBasePermission, + ] + serializer_class = SlackProjectSyncSerializer + model = SlackProjectSync + + def create(self, request, slug, project_id, workspace_integration_id): + try: + serializer = SlackProjectSyncSerializer(data=request.data) + + workspace_integration = WorkspaceIntegration.objects.get( + workspace__slug=slug, pk=workspace_integration_id + ) + + if serializer.is_valid(): + serializer.save( + project_id=project_id, + workspace_integration_id=workspace_integration_id, + ) + + workspace_integration = WorkspaceIntegration.objects.get( + pk=workspace_integration_id, workspace__slug=slug + ) + + _ = ProjectMember.objects.get_or_create( + member=workspace_integration.actor, role=20, project_id=project_id + ) + + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError: + return Response({"error": "Slack is already enabled for the project"}, status=status.HTTP_400_BAD_REQUEST) + except WorkspaceIntegration.DoesNotExist: + return Response( + {"error": "Workspace Integration 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/issue.py b/apiserver/plane/api/views/issue.py index 1f604d271..987677bb2 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -1,13 +1,14 @@ # Python imports import json import random -from itertools import groupby, chain +from itertools import chain # Django imports -from django.db.models import Prefetch, OuterRef, Func, F, Q +from django.db.models import Prefetch, OuterRef, Func, F, Q, Count from django.core.serializers.json import DjangoJSONEncoder from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page +from django.db.models.functions import Coalesce # Third Party imports from rest_framework.response import Response @@ -46,6 +47,7 @@ from plane.db.models import ( Label, IssueLink, IssueAttachment, + State, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results @@ -590,8 +592,31 @@ class SubIssuesEndpoint(BaseAPIView): .prefetch_related("labels") ) - serializer = IssueLiteSerializer(sub_issues, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + state_distribution = ( + State.objects.filter(workspace__slug=slug, project_id=project_id) + .annotate( + state_count=Count( + "state_issue", + filter=Q(state_issue__parent_id=issue_id), + ) + ) + .order_by("group") + .values("group", "state_count") + ) + + result = {item["group"]: item["state_count"] for item in state_distribution} + + serializer = IssueLiteSerializer( + sub_issues, + many=True, + ) + return Response( + { + "sub_issues": serializer.data, + "state_distribution": result, + }, + status=status.HTTP_200_OK, + ) except Exception as e: capture_exception(e) return Response( diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 0abf47c8b..8f0cabeaf 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -109,6 +109,28 @@ class ModuleViewSet(BaseViewSet): .order_by("-is_favorite", "name") ) + def perform_destroy(self, instance): + module_issues = list( + ModuleIssue.objects.filter(module_id=self.kwargs.get("pk")).values_list( + "issue", flat=True + ) + ) + issue_activity.delay( + type="module.activity.deleted", + requested_data=json.dumps( + { + "module_id": str(self.kwargs.get("pk")), + "issues": [str(issue_id) for issue_id in module_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=None, + ) + + return super().perform_destroy(instance) + def create(self, request, slug, project_id): try: project = Project.objects.get(workspace__slug=slug, pk=project_id) @@ -158,6 +180,22 @@ class ModuleIssueViewSet(BaseViewSet): module_id=self.kwargs.get("module_id"), ) + def perform_destroy(self, instance): + issue_activity.delay( + type="module.activity.deleted", + requested_data=json.dumps( + { + "module_id": str(self.kwargs.get("module_id")), + "issues": [str(instance.issue_id)], + } + ), + 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=None, + ) + return super().perform_destroy(instance) + def get_queryset(self): return self.filter_queryset( super() @@ -302,7 +340,7 @@ class ModuleIssueViewSet(BaseViewSet): # Capture Issue Activity issue_activity.delay( - type="issue.activity.updated", + type="module.activity.created", requested_data=json.dumps({"modules_list": issues}), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("pk", None)), diff --git a/apiserver/plane/api/views/oauth.py b/apiserver/plane/api/views/oauth.py index 650a8cc96..184cba951 100644 --- a/apiserver/plane/api/views/oauth.py +++ b/apiserver/plane/api/views/oauth.py @@ -14,7 +14,7 @@ from rest_framework.permissions import AllowAny from rest_framework.views import APIView from rest_framework_simplejwt.tokens import RefreshToken from rest_framework import status - +from sentry_sdk import capture_exception # sso authentication from google.oauth2 import id_token from google.auth.transport import requests as google_auth_request @@ -48,7 +48,7 @@ def validate_google_token(token, client_id): } return data except Exception as e: - print(e) + capture_exception(e) raise exceptions.AuthenticationFailed("Error with Google connection.") @@ -305,8 +305,7 @@ class OauthEndpoint(BaseAPIView): ) return Response(data, status=status.HTTP_201_CREATED) except Exception as e: - print(e) - + capture_exception(e) return Response( { "error": "Something went wrong. Please try again later or contact the support team." diff --git a/apiserver/plane/api/views/page.py b/apiserver/plane/api/views/page.py index b3f5b2dd5..88ce318cf 100644 --- a/apiserver/plane/api/views/page.py +++ b/apiserver/plane/api/views/page.py @@ -96,6 +96,36 @@ class PageViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + def partial_update(self, request, slug, project_id, pk): + try: + page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) + # Only update access if the page owner is the requesting user + if ( + page.access != request.data.get("access", page.access) + and page.owned_by_id != request.user.id + ): + return Response( + { + "error": "Access cannot be updated since this page is owned by someone else" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer = PageSerializer(page, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except Page.DoesNotExist: + return Response( + {"error": "Page 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, + ) + class PageBlockViewSet(BaseViewSet): serializer_class = PageBlockSerializer @@ -344,7 +374,7 @@ class RecentPagesEndpoint(BaseAPIView): status=status.HTTP_200_OK, ) except Exception as e: - print(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/release.py b/apiserver/plane/api/views/release.py new file mode 100644 index 000000000..de827c896 --- /dev/null +++ b/apiserver/plane/api/views/release.py @@ -0,0 +1,21 @@ +# 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 BaseAPIView +from plane.utils.integrations.github import get_release_notes + + +class ReleaseNotesEndpoint(BaseAPIView): + def get(self, request): + try: + release_notes = get_release_notes() + return Response(release_notes, 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/search.py b/apiserver/plane/api/views/search.py index f73a3c9c8..823a1fcc8 100644 --- a/apiserver/plane/api/views/search.py +++ b/apiserver/plane/api/views/search.py @@ -195,7 +195,7 @@ class GlobalSearchEndpoint(BaseAPIView): return Response({"results": results}, status=status.HTTP_200_OK) except Exception as e: - print(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/state.py b/apiserver/plane/api/views/state.py index 4616fcee7..b217a662d 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -1,6 +1,9 @@ # Python imports from itertools import groupby +# Django imports +from django.db import IntegrityError + # Third party imports from rest_framework.response import Response from rest_framework import status @@ -8,10 +11,10 @@ from sentry_sdk import capture_exception # Module imports -from . import BaseViewSet +from . import BaseViewSet, BaseAPIView from plane.api.serializers import StateSerializer from plane.api.permissions import ProjectEntityPermission -from plane.db.models import State +from plane.db.models import State, Issue class StateViewSet(BaseViewSet): @@ -36,6 +39,25 @@ class StateViewSet(BaseViewSet): .distinct() ) + def create(self, request, slug, project_id): + try: + serializer = StateSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError: + return Response( + {"error": "State with the name already exists"}, + 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 list(self, request, slug, project_id): try: state_dict = dict() @@ -66,6 +88,17 @@ class StateViewSet(BaseViewSet): {"error": "Default state cannot be deleted"}, status=False ) + # Check for any issues in the state + issue_exist = Issue.objects.filter(state=pk).exists() + + if issue_exist: + return Response( + { + "error": "The state is not empty, only empty states can be deleted" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + state.delete() return Response(status=status.HTTP_204_NO_CONTENT) except State.DoesNotExist: diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 915ade2fc..8a2791e3b 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -145,7 +145,6 @@ class UserWorkSpacesEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_200_OK) except Exception as e: - print(e) capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, @@ -333,7 +332,6 @@ class JoinWorkspaceEndpoint(BaseAPIView): status=status.HTTP_404_NOT_FOUND, ) except Exception as e: - print(e) capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, @@ -780,7 +778,7 @@ class WorkspaceThemeViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) except Exception as e: - print(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/bgtasks/email_verification_task.py b/apiserver/plane/bgtasks/email_verification_task.py index ee4680e53..1da3a7510 100644 --- a/apiserver/plane/bgtasks/email_verification_task.py +++ b/apiserver/plane/bgtasks/email_verification_task.py @@ -2,6 +2,7 @@ from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from django.utils.html import strip_tags +from django.conf import settings # Third party imports from celery import shared_task @@ -20,7 +21,7 @@ def email_verification(first_name, email, token, current_site): realtivelink = "/request-email-verification/" + "?token=" + str(token) abs_url = "http://" + current_site + realtivelink - from_email_string = f"Team Plane " + from_email_string = settings.EMAIL_FROM subject = f"Verify your Email!" diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index 4598e5f2f..f13f1b89a 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -2,6 +2,7 @@ from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from django.utils.html import strip_tags +from django.conf import settings # Third party imports from celery import shared_task @@ -18,7 +19,7 @@ def forgot_password(first_name, email, uidb64, token, current_site): realtivelink = f"/email-verify/?uidb64={uidb64}&token={token}/" abs_url = "http://" + current_site + realtivelink - from_email_string = f"Team Plane " + from_email_string = settings.EMAIL_FROM subject = f"Verify your Email!" diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py index fba43f6e4..291b71be3 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -27,6 +27,7 @@ from plane.db.models import ( User, ) from .workspace_invitation_task import workspace_invitation +from plane.bgtasks.user_welcome_task import send_welcome_email @shared_task @@ -40,7 +41,7 @@ def service_importer(service, importer_id): # Check if we need to import users as well if len(users): - # For all invited users create the uers + # For all invited users create the users new_users = User.objects.bulk_create( [ User( @@ -56,6 +57,15 @@ def service_importer(service, importer_id): ignore_conflicts=True, ) + [ + send_welcome_email.delay( + str(user.id), + True, + f"{user.email} was imported to Plane from {service}", + ) + for user in new_users + ] + workspace_users = User.objects.filter( email__in=[ user.get("email").strip().lower() diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index c4fde9646..c749d9c15 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -506,119 +506,6 @@ def track_blockings( ) -def track_cycles( - requested_data, - current_instance, - issue_id, - project, - actor, - issue_activities, -): - # Updated Records: - updated_records = current_instance.get("updated_cycle_issues", []) - created_records = json.loads(current_instance.get("created_cycle_issues", [])) - - for updated_record in updated_records: - old_cycle = Cycle.objects.filter( - pk=updated_record.get("old_cycle_id", None) - ).first() - new_cycle = Cycle.objects.filter( - pk=updated_record.get("new_cycle_id", None) - ).first() - - issue_activities.append( - IssueActivity( - issue_id=updated_record.get("issue_id"), - actor=actor, - verb="updated", - old_value=old_cycle.name, - new_value=new_cycle.name, - field="cycles", - project=project, - workspace=project.workspace, - comment=f"{actor.email} updated cycle from {old_cycle.name} to {new_cycle.name}", - old_identifier=old_cycle.id, - new_identifier=new_cycle.id, - ) - ) - - for created_record in created_records: - cycle = Cycle.objects.filter( - pk=created_record.get("fields").get("cycle") - ).first() - - issue_activities.append( - IssueActivity( - issue_id=created_record.get("fields").get("issue"), - actor=actor, - verb="created", - old_value="", - new_value=cycle.name, - field="cycles", - project=project, - workspace=project.workspace, - comment=f"{actor.email} added cycle {cycle.name}", - new_identifier=cycle.id, - ) - ) - - -def track_modules( - requested_data, - current_instance, - issue_id, - project, - actor, - issue_activities, -): - # Updated Records: - updated_records = current_instance.get("updated_module_issues", []) - created_records = json.loads(current_instance.get("created_module_issues", [])) - - for updated_record in updated_records: - old_module = Module.objects.filter( - pk=updated_record.get("old_module_id", None) - ).first() - new_module = Module.objects.filter( - pk=updated_record.get("new_module_id", None) - ).first() - - issue_activities.append( - IssueActivity( - issue_id=updated_record.get("issue_id"), - actor=actor, - verb="updated", - old_value=old_module.name, - new_value=new_module.name, - field="modules", - project=project, - workspace=project.workspace, - comment=f"{actor.email} updated module from {old_module.name} to {new_module.name}", - old_identifier=old_module.id, - new_identifier=new_module.id, - ) - ) - - for created_record in created_records: - module = Module.objects.filter( - pk=created_record.get("fields").get("module") - ).first() - issue_activities.append( - IssueActivity( - issue_id=created_record.get("fields").get("issue"), - actor=actor, - verb="created", - old_value="", - new_value=module.name, - field="modules", - project=project, - workspace=project.workspace, - comment=f"{actor.email} added module {module.name}", - new_identifier=module.id, - ) - ) - - def create_issue_activity( requested_data, current_instance, issue_id, project, actor, issue_activities ): @@ -683,8 +570,6 @@ def update_issue_activity( "assignees_list": track_assignees, "blocks_list": track_blocks, "blockers_list": track_blockings, - "cycles_list": track_cycles, - "modules_list": track_modules, "estimate_point": track_estimate_points, } @@ -788,6 +673,177 @@ def delete_comment_activity( ) +def create_cycle_issue_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 + ) + + # Updated Records: + updated_records = current_instance.get("updated_cycle_issues", []) + created_records = json.loads(current_instance.get("created_cycle_issues", [])) + + for updated_record in updated_records: + old_cycle = Cycle.objects.filter( + pk=updated_record.get("old_cycle_id", None) + ).first() + new_cycle = Cycle.objects.filter( + pk=updated_record.get("new_cycle_id", None) + ).first() + + issue_activities.append( + IssueActivity( + issue_id=updated_record.get("issue_id"), + actor=actor, + verb="updated", + old_value=old_cycle.name, + new_value=new_cycle.name, + field="cycles", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated cycle from {old_cycle.name} to {new_cycle.name}", + old_identifier=old_cycle.id, + new_identifier=new_cycle.id, + ) + ) + + for created_record in created_records: + cycle = Cycle.objects.filter( + pk=created_record.get("fields").get("cycle") + ).first() + + issue_activities.append( + IssueActivity( + issue_id=created_record.get("fields").get("issue"), + actor=actor, + verb="created", + old_value="", + new_value=cycle.name, + field="cycles", + project=project, + workspace=project.workspace, + comment=f"{actor.email} added cycle {cycle.name}", + new_identifier=cycle.id, + ) + ) + + +def delete_cycle_issue_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 + ) + + cycle_id = requested_data.get("cycle_id", "") + cycle = Cycle.objects.filter(pk=cycle_id).first() + issues = requested_data.get("issues") + + for issue in issues: + issue_activities.append( + IssueActivity( + issue_id=issue, + actor=actor, + verb="deleted", + old_value=cycle.name if cycle is not None else "", + new_value="", + field="cycles", + project=project, + workspace=project.workspace, + comment=f"{actor.email} removed this issue from {cycle.name if cycle is not None else None}", + old_identifier=cycle.id if cycle is not None else None, + ) + ) + + +def create_module_issue_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 + ) + + # Updated Records: + updated_records = current_instance.get("updated_module_issues", []) + created_records = json.loads(current_instance.get("created_module_issues", [])) + + for updated_record in updated_records: + old_module = Module.objects.filter( + pk=updated_record.get("old_module_id", None) + ).first() + new_module = Module.objects.filter( + pk=updated_record.get("new_module_id", None) + ).first() + + issue_activities.append( + IssueActivity( + issue_id=updated_record.get("issue_id"), + actor=actor, + verb="updated", + old_value=old_module.name, + new_value=new_module.name, + field="modules", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated module from {old_module.name} to {new_module.name}", + old_identifier=old_module.id, + new_identifier=new_module.id, + ) + ) + + for created_record in created_records: + module = Module.objects.filter( + pk=created_record.get("fields").get("module") + ).first() + issue_activities.append( + IssueActivity( + issue_id=created_record.get("fields").get("issue"), + actor=actor, + verb="created", + old_value="", + new_value=module.name, + field="modules", + project=project, + workspace=project.workspace, + comment=f"{actor.email} added module {module.name}", + new_identifier=module.id, + ) + ) + + +def delete_module_issue_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 + ) + + module_id = requested_data.get("module_id", "") + module = Module.objects.filter(pk=module_id).first() + issues = requested_data.get("issues") + + for issue in issues: + issue_activities.append( + IssueActivity( + issue_id=issue, + actor=actor, + verb="deleted", + old_value=module.name if module is not None else "", + new_value="", + field="modules", + project=project, + workspace=project.workspace, + comment=f"{actor.email} removed this issue from {module.name if module is not None else None}", + old_identifier=module.id if module is not None else None, + ) + ) + + def create_link_activity( requested_data, current_instance, issue_id, project, actor, issue_activities ): @@ -910,6 +966,10 @@ def issue_activity( "comment.activity.created": create_comment_activity, "comment.activity.updated": update_comment_activity, "comment.activity.deleted": delete_comment_activity, + "cycle.activity.created": create_cycle_issue_activity, + "cycle.activity.deleted": delete_cycle_issue_activity, + "module.activity.created": create_module_issue_activity, + "module.activity.deleted": delete_module_issue_activity, "link.activity.created": create_link_activity, "link.activity.updated": update_link_activity, "link.activity.deleted": delete_link_activity, @@ -947,6 +1007,5 @@ def issue_activity( ) 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 89554dcca..00a4e6807 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -2,6 +2,7 @@ from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from django.utils.html import strip_tags +from django.conf import settings # Third party imports from celery import shared_task @@ -14,7 +15,7 @@ def magic_link(email, key, token, current_site): realtivelink = f"/magic-sign-in/?password={token}&key={key}" abs_url = "http://" + current_site + realtivelink - from_email_string = f"Team Plane " + from_email_string = settings.EMAIL_FROM subject = f"Login for Plane" @@ -29,6 +30,5 @@ def magic_link(email, key, token, current_site): msg.send() return except Exception as e: - print(e) capture_exception(e) return diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index 18e539970..2015ffe5e 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -2,6 +2,7 @@ from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from django.utils.html import strip_tags +from django.conf import settings # Third party imports from celery import shared_task @@ -22,7 +23,7 @@ def project_invitation(email, project_id, token, current_site): relativelink = f"/project-member-invitation/{project_member_invite.id}" abs_url = "http://" + current_site + relativelink - from_email_string = f"Team Plane " + from_email_string = settings.EMAIL_FROM subject = f"{project.created_by.first_name or project.created_by.email} invited you to join {project.name} on Plane" @@ -49,6 +50,5 @@ def project_invitation(email, project_id, token, current_site): except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist) as e: return except Exception as e: - print(e) capture_exception(e) return diff --git a/apiserver/plane/bgtasks/user_welcome_task.py b/apiserver/plane/bgtasks/user_welcome_task.py new file mode 100644 index 000000000..c042d0a0b --- /dev/null +++ b/apiserver/plane/bgtasks/user_welcome_task.py @@ -0,0 +1,56 @@ +# Django imports +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from celery import shared_task +from sentry_sdk import capture_exception +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +# Module imports +from plane.db.models import User + + +@shared_task +def send_welcome_email(user_id, created, message): + try: + instance = User.objects.get(pk=user_id) + + if created and not instance.is_bot: + first_name = instance.first_name.capitalize() + to_email = instance.email + from_email_string = settings.EMAIL_FROM + + subject = f"Welcome to Plane ✈️!" + + context = {"first_name": first_name, "email": instance.email} + + html_content = render_to_string( + "emails/auth/user_welcome_email.html", context + ) + + text_content = strip_tags(html_content) + + msg = EmailMultiAlternatives( + subject, text_content, from_email_string, [to_email] + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + + # Send message on slack as well + if settings.SLACK_BOT_TOKEN: + client = WebClient(token=settings.SLACK_BOT_TOKEN) + try: + _ = client.chat_postMessage( + channel="#trackers", + text=message, + ) + except SlackApiError as e: + print(f"Got an error: {e.response['error']}") + return + except Exception as e: + capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index c6e69689b..0ce32eee0 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -27,7 +27,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): ) abs_url = "http://" + current_site + realtivelink - from_email_string = f"Team Plane " + from_email_string = settings.EMAIL_FROM subject = f"{invitor or email} invited you to join {workspace.name} on Plane" diff --git a/apiserver/plane/db/migrations/0029_auto_20230502_0126.py b/apiserver/plane/db/migrations/0029_auto_20230502_0126.py new file mode 100644 index 000000000..373cc39bd --- /dev/null +++ b/apiserver/plane/db/migrations/0029_auto_20230502_0126.py @@ -0,0 +1,58 @@ +# Generated by Django 3.2.18 on 2023-05-01 19:56 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0028_auto_20230414_1703'), + ] + + operations = [ + migrations.AddField( + model_name='cycle', + name='view_props', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='importer', + name='imported_data', + field=models.JSONField(null=True), + ), + migrations.AddField( + model_name='module', + name='view_props', + field=models.JSONField(default=dict), + ), + migrations.CreateModel( + name='SlackProjectSync', + 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)), + ('access_token', models.CharField(max_length=300)), + ('scopes', models.TextField()), + ('bot_user_id', models.CharField(max_length=50)), + ('webhook_url', models.URLField(max_length=1000)), + ('data', models.JSONField(default=dict)), + ('team_id', models.CharField(max_length=30)), + ('team_name', models.CharField(max_length=300)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='slackprojectsync_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_slackprojectsync', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='slackprojectsync_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_slackprojectsync', to='db.workspace')), + ('workspace_integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slack_syncs', to='db.workspaceintegration')), + ], + options={ + 'verbose_name': 'Slack Project Sync', + 'verbose_name_plural': 'Slack Project Syncs', + 'db_table': 'slack_project_syncs', + 'ordering': ('-created_at',), + 'unique_together': {('team_id', 'project')}, + }, + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index b6ffe428c..e32d768e0 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -59,6 +59,7 @@ from .integration import ( GithubRepositorySync, GithubIssueSync, GithubCommentSync, + SlackProjectSync, ) from .importer import Importer diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py index 6ecd3d3b0..c8c43cef4 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -16,6 +16,7 @@ class Cycle(ProjectBaseModel): on_delete=models.CASCADE, related_name="owned_by_cycle", ) + view_props = models.JSONField(default=dict) class Meta: verbose_name = "Cycle" diff --git a/apiserver/plane/db/models/importer.py b/apiserver/plane/db/models/importer.py index a61aae48c..a2d1d3166 100644 --- a/apiserver/plane/db/models/importer.py +++ b/apiserver/plane/db/models/importer.py @@ -33,6 +33,7 @@ class Importer(ProjectBaseModel): token = models.ForeignKey( "db.APIToken", on_delete=models.CASCADE, related_name="importer" ) + imported_data = models.JSONField(null=True) class Meta: verbose_name = "Importer" diff --git a/apiserver/plane/db/models/integration/__init__.py b/apiserver/plane/db/models/integration/__init__.py index 4742a2529..3f2be93b8 100644 --- a/apiserver/plane/db/models/integration/__init__.py +++ b/apiserver/plane/db/models/integration/__init__.py @@ -1,2 +1,3 @@ from .base import Integration, WorkspaceIntegration from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync +from .slack import SlackProjectSync \ No newline at end of file diff --git a/apiserver/plane/db/models/integration/slack.py b/apiserver/plane/db/models/integration/slack.py new file mode 100644 index 000000000..6b29968f6 --- /dev/null +++ b/apiserver/plane/db/models/integration/slack.py @@ -0,0 +1,32 @@ +# Python imports +import uuid + +# Django imports +from django.db import models + +# Module imports +from plane.db.models import ProjectBaseModel + + +class SlackProjectSync(ProjectBaseModel): + access_token = models.CharField(max_length=300) + scopes = models.TextField() + bot_user_id = models.CharField(max_length=50) + webhook_url = models.URLField(max_length=1000) + data = models.JSONField(default=dict) + team_id = models.CharField(max_length=30) + team_name = models.CharField(max_length=300) + workspace_integration = models.ForeignKey( + "db.WorkspaceIntegration", related_name="slack_syncs", on_delete=models.CASCADE + ) + + def __str__(self): + """Return the repo name""" + return f"{self.project.name}" + + class Meta: + unique_together = ["team_id", "project"] + verbose_name = "Slack Project Sync" + verbose_name_plural = "Slack Project Syncs" + db_table = "slack_project_syncs" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py index ec8c401ab..8ad0ec838 100644 --- a/apiserver/plane/db/models/module.py +++ b/apiserver/plane/db/models/module.py @@ -39,6 +39,7 @@ class Module(ProjectBaseModel): through="ModuleMember", through_fields=("module", "member"), ) + view_props = models.JSONField(default=dict) class Meta: unique_together = ["name", "project"] diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 334ec3e13..5a4f487c1 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -109,7 +109,7 @@ def send_welcome_email(sender, instance, created, **kwargs): if created and not instance.is_bot: first_name = instance.first_name.capitalize() to_email = instance.email - from_email_string = f"Team Plane " + from_email_string = settings.EMAIL_FROM subject = f"Welcome to Plane ✈️!" diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index c144eeb0b..f5bff248b 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -174,11 +174,12 @@ EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" # Host for sending e-mail. EMAIL_HOST = os.environ.get("EMAIL_HOST") # Port for sending e-mail. -EMAIL_PORT = 587 +EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587)) # Optional SMTP authentication information for EMAIL_HOST. EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER") EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD") -EMAIL_USE_TLS = True +EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "1") == "1" +EMAIL_FROM = os.environ.get("EMAIL_FROM", "Team Plane ") SIMPLE_JWT = { @@ -210,4 +211,4 @@ SIMPLE_JWT = { CELERY_TIMEZONE = TIME_ZONE CELERY_TASK_SERIALIZER = 'json' -CELERY_ACCEPT_CONTENT = ['application/json'] \ No newline at end of file +CELERY_ACCEPT_CONTENT = ['application/json'] diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index c3bf65588..e03a0b822 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -83,3 +83,6 @@ 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") + + +GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) \ No newline at end of file diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 8f8453aff..e58736472 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -105,7 +105,7 @@ if ( AWS_S3_ADDRESSING_STYLE = "auto" # The full URL to the S3 endpoint. Leave blank to use the default region URL. - AWS_S3_ENDPOINT_URL = "" + AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "") # A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. AWS_S3_KEY_PREFIX = "" @@ -240,7 +240,15 @@ 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()}" +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 +if DOCKERIZED: + CELERY_BROKER_URL = REDIS_URL + CELERY_RESULT_BACKEND = REDIS_URL +else: + CELERY_RESULT_BACKEND = broker_url + CELERY_BROKER_URL = broker_url + +GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py index 384116ba3..d4d0e5e12 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -80,7 +80,7 @@ AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") AWS_S3_ADDRESSING_STYLE = "auto" # The full URL to the S3 endpoint. Leave blank to use the default region URL. -AWS_S3_ENDPOINT_URL = "" +AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "") # A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. AWS_S3_KEY_PREFIX = "" @@ -203,4 +203,6 @@ 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 +CELERY_BROKER_URL = broker_url + +GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) diff --git a/apiserver/plane/utils/integrations/github.py b/apiserver/plane/utils/integrations/github.py index d9185cb10..d9aecece1 100644 --- a/apiserver/plane/utils/integrations/github.py +++ b/apiserver/plane/utils/integrations/github.py @@ -5,6 +5,7 @@ from urllib.parse import urlparse, parse_qs from datetime import datetime, timedelta from cryptography.hazmat.primitives.serialization import load_pem_private_key from cryptography.hazmat.backends import default_backend +from django.conf import settings def get_jwt_token(): @@ -128,3 +129,24 @@ def get_github_repo_details(access_tokens_url, owner, repo): ).json() return open_issues, total_labels, collaborators + + +def get_release_notes(): + token = settings.GITHUB_ACCESS_TOKEN + + if token: + headers = { + "Authorization": "Bearer " + str(token), + "Accept": "application/vnd.github.v3+json", + } + else: + headers = { + "Accept": "application/vnd.github.v3+json", + } + url = "https://api.github.com/repos/makeplane/plane/releases?per_page=5&page=1" + response = requests.get(url, headers=headers) + + if response.status_code != 200: + return {"error": "Unable to render information from Github Repository"} + + return response.json() diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 81ee30bac..8b62da722 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -13,6 +13,17 @@ def filter_state(params, filter, method): return filter +def filter_estimate_point(params, filter, method): + if method == "GET": + estimate_points = params.get("estimate_point").split(",") + if len(estimate_points) and "" not in estimate_points: + filter["estimate_point__in"] = estimate_points + else: + if params.get("estimate_point", None) and len(params.get("estimate_point")): + filter["estimate_point__in"] = params.get("estimate_point") + return filter + + def filter_priority(params, filter, method): if method == "GET": priorties = params.get("priority").split(",") @@ -192,6 +203,7 @@ def issue_filters(query_params, method): ISSUE_FILTER = { "state": filter_state, + "estimate_point": filter_estimate_point, "priority": filter_priority, "parent": filter_parent, "labels": filter_labels, diff --git a/apps/app/.env.example b/apps/app/.env.example index 1e2576dfc..9e41ba88d 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -1,5 +1,6 @@ # Replace with your instance Public IP # NEXT_PUBLIC_API_BASE_URL = "http://localhost" +NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS= NEXT_PUBLIC_GOOGLE_CLIENTID="" NEXT_PUBLIC_GITHUB_APP_NAME="" NEXT_PUBLIC_GITHUB_ID="" @@ -7,4 +8,5 @@ NEXT_PUBLIC_SENTRY_DSN="" NEXT_PUBLIC_ENABLE_OAUTH=0 NEXT_PUBLIC_ENABLE_SENTRY=0 NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0 -NEXT_PUBLIC_TRACK_EVENTS=0 \ No newline at end of file +NEXT_PUBLIC_TRACK_EVENTS=0 +NEXT_PUBLIC_SLACK_CLIENT_ID="" diff --git a/apps/app/components/account/email-code-form.tsx b/apps/app/components/account/email-code-form.tsx index 5e4c49b1a..48288f77e 100644 --- a/apps/app/components/account/email-code-form.tsx +++ b/apps/app/components/account/email-code-form.tsx @@ -92,13 +92,13 @@ export const EmailCodeForm = ({ onSuccess }: any) => { <>
{(codeSent || codeResent) && ( -
+
-
-

+

{codeResent ? "Please check your mail for new code." : "Please check your mail for code."} @@ -141,7 +141,9 @@ export const EmailCodeForm = ({ onSuccess }: any) => { diff --git a/apps/app/components/account/google-login.tsx b/apps/app/components/account/google-login.tsx index 478ffc67e..237439def 100644 --- a/apps/app/components/account/google-login.tsx +++ b/apps/app/components/account/google-login.tsx @@ -47,7 +47,7 @@ export const GoogleLoginButton: FC = (props) => { return ( <>