diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml new file mode 100644 index 000000000..4e672326c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/--bug-report.yaml @@ -0,0 +1,56 @@ +name: Bug report +description: Create a bug report to help us improve Plane +title: "[bug]: " +labels: [bug, need testing] +body: +- type: markdown + attributes: + value: | + Thank you for taking the time to fill out this bug report. +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for the bug you encountered + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: Current behavior + description: A concise description of what you're experiencing and what you expect + placeholder: | + When I do , happens and I see the error message attached below: + ```...``` + What I expect is + validations: + required: true +- type: textarea + attributes: + label: Steps to reproduce + description: Add steps to reproduce this behaviour, include console or network logs and screenshots + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true +- type: dropdown + id: env + attributes: + label: Environment + options: + - Production + - Deploy preview + validations: + required: true +- type: dropdown + id: version + attributes: + label: Version + options: + - Cloud + - Self-hosted + - Local + validations: + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/--feature-request.yaml b/.github/ISSUE_TEMPLATE/--feature-request.yaml new file mode 100644 index 000000000..b7ba11679 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/--feature-request.yaml @@ -0,0 +1,28 @@ +name: Feature request +description: Suggest a feature to improve Plane +title: "[feature]: " +labels: [feature] +body: +- type: markdown + attributes: + value: | + Thank you for taking the time to request a feature for Plane +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue related to this feature request already exists + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: Summary + description: One paragraph description of the feature + validations: + required: true +- type: textarea + attributes: + label: Why should this be worked on? + description: A concise description of the problems or use cases for this feature request + validations: + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 000000000..29c267831 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1,6 @@ +contact_links: + - name: Help and support + about: Reach out to us on our Discord server or GitHub discussions. + - name: Dedicated support + url: mailto:support@plane.so + about: Write to us if you'd like dedicated support using Plane diff --git a/.github/workflows/push-image-backend.yml b/.github/workflows/push-image-backend.yml new file mode 100644 index 000000000..abb833922 --- /dev/null +++ b/.github/workflows/push-image-backend.yml @@ -0,0 +1,54 @@ +name: Build Api Server Docker Image + +on: + push: + branches: + - 'develop' + - 'master' + tags: + - '*' + +jobs: + build_push_backend: + name: Build Api Server Docker Image + runs-on: ubuntu-20.04 + permissions: + contents: read + packages: write + + steps: + - name: Check out the repo + uses: actions/checkout@v3.3.0 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2.1.0 + with: + platforms: linux/arm64,linux/amd64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2.5.0 + + - name: Login to Github Container Registry + uses: docker/login-action@v2.1.0 + with: + registry: "ghcr.io" + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4.3.0 + with: + images: ghcr.io/${{ github.repository }}-backend + + - name: Build Api Server + uses: docker/build-push-action@v4.0.0 + with: + context: ./apiserver + file: ./apiserver/Dockerfile.api + platforms: linux/arm64,linux/amd64 + push: true + cache-from: type=gha + cache-to: type=gha + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/push-image-frontend.yml b/.github/workflows/push-image-frontend.yml new file mode 100644 index 000000000..c6a3bf1b8 --- /dev/null +++ b/.github/workflows/push-image-frontend.yml @@ -0,0 +1,54 @@ +name: Build Frontend Docker Image + +on: + push: + branches: + - 'develop' + - 'master' + tags: + - '*' + +jobs: + build_push_frontend: + name: Build Frontend Docker Image + runs-on: ubuntu-20.04 + permissions: + contents: read + packages: write + + steps: + - name: Check out the repo + uses: actions/checkout@v3.3.0 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2.1.0 + with: + platforms: linux/arm64,linux/amd64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2.5.0 + + - name: Login to Github Container Registry + uses: docker/login-action@v2.1.0 + with: + registry: "ghcr.io" + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4.3.0 + with: + images: ghcr.io/${{ github.repository }}-frontend + + - name: Build Frontend Server + uses: docker/build-push-action@v4.0.0 + with: + context: . + file: ./apps/app/Dockerfile.web + platforms: linux/arm64,linux/amd64 + push: true + cache-from: type=gha + cache-to: type=gha + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/apiserver/.env.example b/apiserver/.env.example index 3d502fadb..2241e2217 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -1,22 +1,21 @@ -SECRET_KEY="<-- django secret -->" DJANGO_SETTINGS_MODULE="plane.settings.production" # Database -DATABASE_URL=postgres://plane:plane@db:5432/plane +DATABASE_URL=postgres://plane:xyzzyspoon@db:5432/plane # Cache REDIS_URL=redis://redis:6379/ # SMPT -EMAIL_HOST="<-- email smtp -->" -EMAIL_HOST_USER="<-- email host user -->" -EMAIL_HOST_PASSWORD="<-- email host password -->" +EMAIL_HOST="" +EMAIL_HOST_USER="" +EMAIL_HOST_PASSWORD="" # AWS -AWS_REGION="<-- aws region -->" -AWS_ACCESS_KEY_ID="<-- aws access key -->" -AWS_SECRET_ACCESS_KEY="<-- aws secret acess key -->" -AWS_S3_BUCKET_NAME="<-- aws s3 bucket name -->" +AWS_REGION="" +AWS_ACCESS_KEY_ID="" +AWS_SECRET_ACCESS_KEY="" +AWS_S3_BUCKET_NAME="" # FE WEB_URL="localhost/" # OAUTH -GITHUB_CLIENT_SECRET="<-- github secret -->" +GITHUB_CLIENT_SECRET="" # Flags DISABLE_COLLECTSTATIC=1 DOCKERIZED=1 diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 9814ace37..57bff15c2 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -10,6 +10,7 @@ from .workspace import ( WorkSpaceMemberSerializer, TeamSerializer, WorkSpaceMemberInviteSerializer, + WorkspaceLiteSerializer, ) from .project import ( ProjectSerializer, @@ -18,10 +19,11 @@ from .project import ( ProjectMemberInviteSerializer, ProjectIdentifierSerializer, ProjectFavoriteSerializer, + ProjectLiteSerializer, ) -from .state import StateSerializer +from .state import StateSerializer, StateLiteSerializer from .shortcut import ShortCutSerializer -from .view import ViewSerializer +from .view import IssueViewSerializer, IssueViewFavoriteSerializer from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer from .asset import FileAssetSerializer from .issue import ( @@ -38,6 +40,7 @@ from .issue import ( IssueFlatSerializer, IssueStateSerializer, IssueLinkSerializer, + IssueLiteSerializer, ) from .module import ( @@ -58,3 +61,7 @@ from .integration import ( GithubRepositorySyncSerializer, GithubCommentSyncSerializer, ) + +from .importer import ImporterSerializer + +from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index d96a70d8c..5c06a28e7 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -5,12 +5,23 @@ from rest_framework import serializers from .base import BaseSerializer from .user import UserLiteSerializer from .issue import IssueStateSerializer +from .workspace import WorkspaceLiteSerializer +from .project import ProjectLiteSerializer from plane.db.models import Cycle, CycleIssue, CycleFavorite class CycleSerializer(BaseSerializer): owned_by = UserLiteSerializer(read_only=True) is_favorite = serializers.BooleanField(read_only=True) + total_issues = serializers.IntegerField(read_only=True) + cancelled_issues = serializers.IntegerField(read_only=True) + completed_issues = serializers.IntegerField(read_only=True) + started_issues = serializers.IntegerField(read_only=True) + unstarted_issues = serializers.IntegerField(read_only=True) + backlog_issues = serializers.IntegerField(read_only=True) + + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + project_detail = ProjectLiteSerializer(read_only=True, source="project") class Meta: model = Cycle diff --git a/apiserver/plane/api/serializers/importer.py b/apiserver/plane/api/serializers/importer.py new file mode 100644 index 000000000..28f2153c8 --- /dev/null +++ b/apiserver/plane/api/serializers/importer.py @@ -0,0 +1,12 @@ +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from plane.db.models import Importer + + +class ImporterSerializer(BaseSerializer): + initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True) + + class Meta: + model = Importer + fields = "__all__" diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index e934f5cbd..c5d53f838 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -4,10 +4,10 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer from .user import UserLiteSerializer -from .state import StateSerializer +from .state import StateSerializer, StateLiteSerializer from .user import UserLiteSerializer -from .project import ProjectSerializer -from .workspace import WorkSpaceSerializer +from .project import ProjectSerializer, ProjectLiteSerializer +from .workspace import WorkspaceLiteSerializer from plane.db.models import ( User, Issue, @@ -50,8 +50,8 @@ class IssueFlatSerializer(BaseSerializer): class IssueCreateSerializer(BaseSerializer): state_detail = StateSerializer(read_only=True, source="state") created_by_detail = UserLiteSerializer(read_only=True, source="created_by") - project_detail = ProjectSerializer(read_only=True, source="project") - workspace_detail = WorkSpaceSerializer(read_only=True, source="workspace") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") assignees_list = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), @@ -244,6 +244,7 @@ class IssueCreateSerializer(BaseSerializer): class IssueActivitySerializer(BaseSerializer): actor_detail = UserLiteSerializer(read_only=True, source="actor") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") class Meta: model = IssueActivity @@ -305,6 +306,16 @@ class LabelSerializer(BaseSerializer): ] +class LabelLiteSerializer(BaseSerializer): + class Meta: + model = Label + fields = [ + "id", + "name", + "color", + ] + + class IssueLabelSerializer(BaseSerializer): # label_details = LabelSerializer(read_only=True, source="label") @@ -434,6 +445,8 @@ class IssueStateSerializer(BaseSerializer): project_detail = ProjectSerializer(read_only=True, source="project") label_details = LabelSerializer(read_only=True, source="labels", many=True) assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + sub_issues_count = serializers.IntegerField(read_only=True) + bridge_id = serializers.UUIDField(read_only=True) class Meta: model = Issue @@ -466,3 +479,29 @@ class IssueSerializer(BaseSerializer): "created_at", "updated_at", ] + + +class IssueLiteSerializer(BaseSerializer): + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + state_detail = StateLiteSerializer(read_only=True, source="state") + label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + sub_issues_count = serializers.IntegerField(read_only=True) + cycle_id = serializers.UUIDField(read_only=True) + module_id = serializers.UUIDField(read_only=True) + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "start_date", + "target_date", + "completed_at", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] diff --git a/apiserver/plane/api/serializers/module.py b/apiserver/plane/api/serializers/module.py index bb317a330..8e976d318 100644 --- a/apiserver/plane/api/serializers/module.py +++ b/apiserver/plane/api/serializers/module.py @@ -4,10 +4,18 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer from .user import UserLiteSerializer -from .project import ProjectSerializer +from .project import ProjectSerializer, ProjectLiteSerializer +from .workspace import WorkspaceLiteSerializer from .issue import IssueStateSerializer -from plane.db.models import User, Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite +from plane.db.models import ( + User, + Module, + ModuleMember, + ModuleIssue, + ModuleLink, + ModuleFavorite, +) class ModuleWriteSerializer(BaseSerializer): @@ -17,6 +25,9 @@ class ModuleWriteSerializer(BaseSerializer): required=False, ) + project_detail = ProjectLiteSerializer(source="project", read_only=True) + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + class Meta: model = Module fields = "__all__" @@ -133,9 +144,14 @@ class ModuleSerializer(BaseSerializer): project_detail = ProjectSerializer(read_only=True, source="project") lead_detail = UserLiteSerializer(read_only=True, source="lead") members_detail = UserLiteSerializer(read_only=True, many=True, source="members") - issue_module = ModuleIssueSerializer(read_only=True, many=True) link_module = ModuleLinkSerializer(read_only=True, many=True) is_favorite = serializers.BooleanField(read_only=True) + total_issues = serializers.IntegerField(read_only=True) + cancelled_issues = serializers.IntegerField(read_only=True) + completed_issues = serializers.IntegerField(read_only=True) + started_issues = serializers.IntegerField(read_only=True) + unstarted_issues = serializers.IntegerField(read_only=True) + backlog_issues = serializers.IntegerField(read_only=True) class Meta: model = Module @@ -149,6 +165,7 @@ class ModuleSerializer(BaseSerializer): "updated_at", ] + class ModuleFavoriteSerializer(BaseSerializer): module_detail = ModuleFlatSerializer(source="module", read_only=True) diff --git a/apiserver/plane/api/serializers/page.py b/apiserver/plane/api/serializers/page.py new file mode 100644 index 000000000..1eafe8966 --- /dev/null +++ b/apiserver/plane/api/serializers/page.py @@ -0,0 +1,105 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .issue import IssueFlatSerializer, LabelSerializer +from .workspace import WorkspaceLiteSerializer +from .project import ProjectLiteSerializer +from plane.db.models import Page, PageBlock, PageFavorite, PageLabel, Label + + +class PageBlockSerializer(BaseSerializer): + issue_detail = IssueFlatSerializer(source="issue", read_only=True) + project_detail = ProjectLiteSerializer(source="project", read_only=True) + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + + class Meta: + model = PageBlock + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "page", + ] + + +class PageSerializer(BaseSerializer): + is_favorite = serializers.BooleanField(read_only=True) + label_details = LabelSerializer(read_only=True, source="labels", many=True) + labels_list = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + write_only=True, + required=False, + ) + blocks = PageBlockSerializer(read_only=True, many=True) + project_detail = ProjectLiteSerializer(source="project", read_only=True) + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + + class Meta: + model = Page + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "owned_by", + ] + + def create(self, validated_data): + labels = validated_data.pop("labels_list", None) + project_id = self.context["project_id"] + owned_by_id = self.context["owned_by_id"] + page = Page.objects.create( + **validated_data, project_id=project_id, owned_by_id=owned_by_id + ) + + if labels is not None: + PageLabel.objects.bulk_create( + [ + PageLabel( + label=label, + page=page, + project_id=project_id, + workspace_id=page.workspace_id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + return page + + def update(self, instance, validated_data): + labels = validated_data.pop("labels_list", None) + if labels is not None: + PageLabel.objects.filter(page=instance).delete() + PageLabel.objects.bulk_create( + [ + PageLabel( + label=label, + page=instance, + project_id=instance.project_id, + workspace_id=instance.workspace_id, + created_by_id=instance.created_by_id, + updated_by_id=instance.updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + return super().update(instance, validated_data) + + +class PageFavoriteSerializer(BaseSerializer): + page_detail = PageSerializer(source="page", read_only=True) + + class Meta: + model = PageFavorite + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "user", + ] diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index 61d09b4a8..bf06b3fa2 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -6,7 +6,7 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer -from plane.api.serializers.workspace import WorkSpaceSerializer +from plane.api.serializers.workspace import WorkSpaceSerializer, WorkspaceLiteSerializer from plane.api.serializers.user import UserLiteSerializer from plane.db.models import ( Project, @@ -18,6 +18,8 @@ from plane.db.models import ( class ProjectSerializer(BaseSerializer): + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + class Meta: model = Project fields = "__all__" @@ -56,12 +58,15 @@ class ProjectSerializer(BaseSerializer): project_identifier = ProjectIdentifier.objects.filter( name=identifier, workspace_id=instance.workspace_id ).first() - if project_identifier is None: project = super().update(instance, validated_data) - _ = ProjectIdentifier.objects.update(name=identifier, project=project) + project_identifier = ProjectIdentifier.objects.filter( + project=project + ).first() + if project_identifier is not None: + project_identifier.name = identifier + project_identifier.save() return project - # If found check if the project_id to be updated and identifier project id is same if project_identifier.project_id == instance.id: # If same pass update @@ -118,3 +123,10 @@ class ProjectFavoriteSerializer(BaseSerializer): "workspace", "user", ] + + +class ProjectLiteSerializer(BaseSerializer): + class Meta: + model = Project + fields = ["id", "identifier", "name"] + read_only_fields = fields diff --git a/apiserver/plane/api/serializers/state.py b/apiserver/plane/api/serializers/state.py index 6917f8d69..097bc4c93 100644 --- a/apiserver/plane/api/serializers/state.py +++ b/apiserver/plane/api/serializers/state.py @@ -1,10 +1,15 @@ # Module imports from .base import BaseSerializer +from .workspace import WorkspaceLiteSerializer +from .project import ProjectLiteSerializer from plane.db.models import State class StateSerializer(BaseSerializer): + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + class Meta: model = State fields = "__all__" @@ -12,3 +17,15 @@ class StateSerializer(BaseSerializer): "workspace", "project", ] + + +class StateLiteSerializer(BaseSerializer): + class Meta: + model = State + fields = [ + "id", + "name", + "color", + "group", + ] + read_only_fields = fields diff --git a/apiserver/plane/api/serializers/view.py b/apiserver/plane/api/serializers/view.py index 23ac768ef..021bcfb72 100644 --- a/apiserver/plane/api/serializers/view.py +++ b/apiserver/plane/api/serializers/view.py @@ -1,14 +1,58 @@ +# Third party imports +from rest_framework import serializers + # Module imports from .base import BaseSerializer - -from plane.db.models import View +from .workspace import WorkspaceLiteSerializer +from .project import ProjectLiteSerializer +from plane.db.models import IssueView, IssueViewFavorite +from plane.utils.issue_filters import issue_filters -class ViewSerializer(BaseSerializer): +class IssueViewSerializer(BaseSerializer): + is_favorite = serializers.BooleanField(read_only=True) + project_detail = ProjectLiteSerializer(source="project", read_only=True) + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + class Meta: - model = View + model = IssueView fields = "__all__" read_only_fields = [ "workspace", "project", + "query", + ] + + 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") + 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"]} + ) + + validated_data["query"] = issue_filters(query_params, "PATCH") + return super().update(instance, validated_data) + + +class IssueViewFavoriteSerializer(BaseSerializer): + view_detail = IssueViewSerializer(source="issue_view", read_only=True) + + class Meta: + model = IssueViewFavorite + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "user", ] diff --git a/apiserver/plane/api/serializers/workspace.py b/apiserver/plane/api/serializers/workspace.py index 6936101ec..7b3cb1896 100644 --- a/apiserver/plane/api/serializers/workspace.py +++ b/apiserver/plane/api/serializers/workspace.py @@ -10,7 +10,6 @@ from plane.db.models import Workspace, WorkspaceMember, Team, WorkspaceMemberInv class WorkSpaceSerializer(BaseSerializer): - owner = UserLiteSerializer(read_only=True) total_members = serializers.IntegerField(read_only=True) @@ -28,7 +27,6 @@ class WorkSpaceSerializer(BaseSerializer): class WorkSpaceMemberSerializer(BaseSerializer): - member = UserLiteSerializer(read_only=True) workspace = WorkSpaceSerializer(read_only=True) @@ -38,7 +36,6 @@ class WorkSpaceMemberSerializer(BaseSerializer): class WorkSpaceMemberInviteSerializer(BaseSerializer): - workspace = WorkSpaceSerializer(read_only=True) class Meta: @@ -47,7 +44,6 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer): class TeamSerializer(BaseSerializer): - members_detail = UserLiteSerializer(read_only=True, source="members", many=True) members = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), @@ -93,3 +89,14 @@ class TeamSerializer(BaseSerializer): return super().update(instance, validated_data) else: return super().update(instance, validated_data) + + +class WorkspaceLiteSerializer(BaseSerializer): + class Meta: + model = Workspace + fields = [ + "name", + "slug", + "id", + ] + read_only_fields = fields diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index e75c29c12..d408be37e 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -21,6 +21,7 @@ from plane.api.views import ( # User UserEndpoint, UpdateUserOnBoardedEndpoint, + UserActivityEndpoint, ## End User # Workspaces WorkSpaceViewSet, @@ -38,9 +39,13 @@ from plane.api.views import ( AddTeamToProjectEndpoint, UserLastProjectWithWorkspaceEndpoint, UserWorkspaceInvitationEndpoint, + UserActivityGraphEndpoint, + UserIssueCompletedGraphEndpoint, + UserWorkspaceDashboardEndpoint, ## End Workspaces # File Assets FileAssetEndpoint, + UserAssetsEndpoint, ## End File Assets # Projects ProjectViewSet, @@ -61,13 +66,14 @@ from plane.api.views import ( IssueCommentViewSet, UserWorkSpaceIssues, BulkDeleteIssuesEndpoint, + BulkImportIssuesEndpoint, ProjectUserViewsEndpoint, TimeLineIssueViewSet, IssuePropertyViewSet, LabelViewSet, SubIssuesEndpoint, IssueLinkViewSet, - ModuleLinkViewSet, + BulkCreateIssueLabelsEndpoint, ## End Issues # States StateViewSet, @@ -76,7 +82,9 @@ from plane.api.views import ( ShortCutViewSet, ## End Shortcuts # Views - ViewViewSet, + IssueViewViewSet, + ViewIssuesEndpoint, + IssueViewFavoriteViewSet, ## End Views # Cycles CycleViewSet, @@ -86,12 +94,26 @@ from plane.api.views import ( CompletedCyclesEndpoint, CycleFavoriteViewSet, DraftCyclesEndpoint, + TransferCycleIssueEndpoint, + InCompleteCyclesEndpoint, ## End Cycles # Modules ModuleViewSet, ModuleIssueViewSet, ModuleFavoriteViewSet, + ModuleLinkViewSet, + BulkImportModulesEndpoint, ## End Modules + # Pages + PageViewSet, + PageBlockViewSet, + PageFavoriteViewSet, + CreateIssueFromPageBlockEndpoint, + RecentPagesEndpoint, + FavoritePagesEndpoint, + MyPagesEndpoint, + CreatedbyOtherPagesEndpoint, + ## End Pages # Api Tokens ApiTokenEndpoint, ## End Api Tokens @@ -102,7 +124,19 @@ from plane.api.views import ( GithubRepositorySyncViewSet, GithubIssueSyncViewSet, GithubCommentSyncViewSet, + BulkCreateGithubIssueSyncEndpoint, ## End Integrations + # Importer + ServiceIssueImportSummaryEndpoint, + ImportServiceEndpoint, + UpdateServiceImportStatusEndpoint, + ## End importer + # Search + GlobalSearchEndpoint, + ## End Search + # Gpt + GPTIntegrationEndpoint, + ## End Gpt ) @@ -153,6 +187,7 @@ urlpatterns = [ UpdateUserOnBoardedEndpoint.as_view(), name="change-password", ), + path("users/activities/", UserActivityEndpoint.as_view(), name="user-activities"), # user workspaces path( "users/me/workspaces/", @@ -176,6 +211,23 @@ urlpatterns = [ name="workspace", ), # user join workspace + # User Graphs + path( + "users/me/workspaces//activity-graph/", + UserActivityGraphEndpoint.as_view(), + name="user-activity-graph", + ), + path( + "users/me/workspaces//issues-completed-graph/", + UserIssueCompletedGraphEndpoint.as_view(), + name="completed-graph", + ), + path( + "users/me/workspaces//dashboard/", + UserWorkspaceDashboardEndpoint.as_view(), + name="user-workspace-dashboard", + ), + ## User Graph path( "users/me/invitations/workspaces///join/", JoinWorkspaceEndpoint.as_view(), @@ -452,7 +504,7 @@ urlpatterns = [ # Views path( "workspaces//projects//views/", - ViewViewSet.as_view( + IssueViewViewSet.as_view( { "get": "list", "post": "create", @@ -462,7 +514,7 @@ urlpatterns = [ ), path( "workspaces//projects//views//", - ViewViewSet.as_view( + IssueViewViewSet.as_view( { "get": "retrieve", "put": "update", @@ -472,6 +524,30 @@ urlpatterns = [ ), name="project-view", ), + path( + "workspaces//projects//views//issues/", + ViewIssuesEndpoint.as_view(), + name="project-view-issues", + ), + path( + "workspaces//projects//user-favorite-views/", + IssueViewFavoriteViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="user-favorite-view", + ), + path( + "workspaces//projects//user-favorite-views//", + IssueViewFavoriteViewSet.as_view( + { + "delete": "destroy", + } + ), + name="user-favorite-view", + ), ## End Views ## Cycles path( @@ -557,6 +633,16 @@ urlpatterns = [ ), name="user-favorite-cycle", ), + path( + "workspaces//projects//cycles//transfer-issues/", + TransferCycleIssueEndpoint.as_view(), + name="transfer-issues", + ), + path( + "workspaces//projects//incomplete-cycles/", + InCompleteCyclesEndpoint.as_view(), + name="transfer-issues", + ), ## End Cycles # Issue path( @@ -608,9 +694,20 @@ urlpatterns = [ ), name="project-issue-labels", ), + path( + "workspaces//projects//bulk-create-labels/", + BulkCreateIssueLabelsEndpoint.as_view(), + name="project-bulk-labels", + ), path( "workspaces//projects//bulk-delete-issues/", BulkDeleteIssuesEndpoint.as_view(), + name="project-issues-bulk", + ), + path( + "workspaces//projects//bulk-import-issues//", + BulkImportIssuesEndpoint.as_view(), + name="project-issues-bulk", ), path( "workspaces//my-issues/", @@ -728,12 +825,22 @@ urlpatterns = [ path( "workspaces//file-assets/", FileAssetEndpoint.as_view(), - name="File Assets", + name="file-assets", ), path( - "workspaces//file-assets//", + "workspaces/file-assets///", FileAssetEndpoint.as_view(), - name="File Assets", + name="file-assets", + ), + path( + "users/file-assets/", + UserAssetsEndpoint.as_view(), + name="user-file-assets", + ), + path( + "users/file-assets//", + UserAssetsEndpoint.as_view(), + name="user-file-assets", ), ## End File Assets ## Modules @@ -822,7 +929,100 @@ urlpatterns = [ ), name="user-favorite-module", ), + path( + "workspaces//projects//bulk-import-modules//", + BulkImportModulesEndpoint.as_view(), + name="bulk-modules-create", + ), ## End Modules + # Pages + path( + "workspaces//projects//pages/", + PageViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-pages", + ), + path( + "workspaces//projects//pages//", + PageViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-pages", + ), + path( + "workspaces//projects//pages//page-blocks/", + PageBlockViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-page-blocks", + ), + path( + "workspaces//projects//pages//page-blocks//", + PageBlockViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-page-blocks", + ), + path( + "workspaces//projects//user-favorite-pages/", + PageFavoriteViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="user-favorite-pages", + ), + path( + "workspaces//projects//user-favorite-pages//", + PageFavoriteViewSet.as_view( + { + "delete": "destroy", + } + ), + name="user-favorite-pages", + ), + path( + "workspaces//projects//pages//page-blocks//issues/", + CreateIssueFromPageBlockEndpoint.as_view(), + name="page-block-issues", + ), + path( + "workspaces//projects//pages/recent-pages/", + RecentPagesEndpoint.as_view(), + name="recent-pages", + ), + path( + "workspaces//projects//pages/favorite-pages/", + FavoritePagesEndpoint.as_view(), + name="recent-pages", + ), + path( + "workspaces//projects//pages/my-pages/", + MyPagesEndpoint.as_view(), + name="user-pages", + ), + path( + "workspaces//projects//pages/created-by-other-pages/", + CreatedbyOtherPagesEndpoint.as_view(), + name="created-by-other-pages", + ), + ## End Pages # API Tokens path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"), path("api-tokens//", ApiTokenEndpoint.as_view(), name="api-tokens"), @@ -909,6 +1109,10 @@ urlpatterns = [ } ), ), + path( + "workspaces//projects//github-repository-sync//bulk-create-github-issue-sync/", + BulkCreateGithubIssueSyncEndpoint.as_view(), + ), path( "workspaces//projects//github-repository-sync//github-issue-sync//", GithubIssueSyncViewSet.as_view( @@ -938,4 +1142,40 @@ urlpatterns = [ ), ## End Github Integrations ## End Integrations + # Importer + path( + "workspaces//importers//", + ServiceIssueImportSummaryEndpoint.as_view(), + name="importer", + ), + path( + "workspaces//projects/importers//", + ImportServiceEndpoint.as_view(), + name="importer", + ), + path( + "workspaces//importers/", + ImportServiceEndpoint.as_view(), + name="importer", + ), + path( + "workspaces//projects//service//importers//", + UpdateServiceImportStatusEndpoint.as_view(), + name="importer", + ), + ## End Importer + # Search + path( + "workspaces//projects//search/", + GlobalSearchEndpoint.as_view(), + name="global-search", + ), + ## End Search + # Gpt + path( + "workspaces//projects//ai-assistant/", + GPTIntegrationEndpoint.as_view(), + name="importer", + ), + ## End Gpt ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 2556fc7d9..b6171d68b 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -16,6 +16,7 @@ from .project import ( from .people import ( UserEndpoint, UpdateUserOnBoardedEndpoint, + UserActivityEndpoint, ) from .oauth import OauthEndpoint @@ -36,10 +37,13 @@ from .workspace import ( UserLastProjectWithWorkspaceEndpoint, WorkspaceMemberUserEndpoint, WorkspaceMemberUserViewsEndpoint, + UserActivityGraphEndpoint, + UserIssueCompletedGraphEndpoint, + UserWorkspaceDashboardEndpoint, ) from .state import StateViewSet from .shortcut import ShortCutViewSet -from .view import ViewViewSet +from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet from .cycle import ( CycleViewSet, CycleIssueViewSet, @@ -48,8 +52,10 @@ from .cycle import ( CompletedCyclesEndpoint, CycleFavoriteViewSet, DraftCyclesEndpoint, + TransferCycleIssueEndpoint, + InCompleteCyclesEndpoint, ) -from .asset import FileAssetEndpoint +from .asset import FileAssetEndpoint, UserAssetsEndpoint from .issue import ( IssueViewSet, WorkSpaceIssuesEndpoint, @@ -62,6 +68,7 @@ from .issue import ( UserWorkSpaceIssues, SubIssuesEndpoint, IssueLinkViewSet, + BulkCreateIssueLabelsEndpoint, ) from .auth_extended import ( @@ -96,4 +103,29 @@ from .integration import ( GithubRepositorySyncViewSet, GithubCommentSyncViewSet, GithubRepositoriesEndpoint, + BulkCreateGithubIssueSyncEndpoint, ) + +from .importer import ( + ServiceIssueImportSummaryEndpoint, + ImportServiceEndpoint, + UpdateServiceImportStatusEndpoint, + BulkImportIssuesEndpoint, + BulkImportModulesEndpoint, +) + +from .page import ( + PageViewSet, + PageBlockViewSet, + PageFavoriteViewSet, + CreateIssueFromPageBlockEndpoint, + RecentPagesEndpoint, + FavoritePagesEndpoint, + MyPagesEndpoint, + CreatedbyOtherPagesEndpoint, +) + +from .search import GlobalSearchEndpoint + + +from .gpt import GPTIntegrationEndpoint diff --git a/apiserver/plane/api/views/asset.py b/apiserver/plane/api/views/asset.py index e5af2c080..abdee4812 100644 --- a/apiserver/plane/api/views/asset.py +++ b/apiserver/plane/api/views/asset.py @@ -17,8 +17,9 @@ class FileAssetEndpoint(BaseAPIView): A viewset for viewing and editing task instances. """ - def get(self, request, slug): - files = FileAsset.objects.filter(workspace__slug=slug) + def get(self, request, workspace_id, asset_key): + asset_key = str(workspace_id) + "/" + asset_key + files = FileAsset.objects.filter(asset=asset_key) serializer = FileAssetSerializer(files, context={"request": request}, many=True) return Response(serializer.data) @@ -42,9 +43,55 @@ class FileAssetEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - def delete(self, request, slug, pk): + def delete(self, request, workspace_id, asset_key): try: - file_asset = FileAsset.objects.get(pk=pk, workspace__slug=slug) + asset_key = str(workspace_id) + "/" + asset_key + file_asset = FileAsset.objects.get(asset=asset_key) + # Delete the file from storage + file_asset.asset.delete(save=False) + # Delete the file object + file_asset.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except FileAsset.DoesNotExist: + return Response( + {"error": "File Asset doesn't exist"}, 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 UserAssetsEndpoint(BaseAPIView): + def get(self, request, asset_key): + try: + files = FileAsset.objects.filter(asset=asset_key, created_by=request.user) + serializer = FileAssetSerializer(files, context={"request": request}) + return Response(serializer.data) + except FileAsset.DoesNotExist: + return Response( + {"error": "File Asset does not exist"}, status=status.HTTP_404_NOT_FOUND + ) + + def post(self, request): + try: + serializer = FileAssetSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + 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, asset_key): + try: + file_asset = FileAsset.objects.get(asset=asset_key, created_by=request.user) # Delete the file from storage file_asset.asset.delete(save=False) # Delete the file object diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py index 58d75a049..a63f199ad 100644 --- a/apiserver/plane/api/views/authentication.py +++ b/apiserver/plane/api/views/authentication.py @@ -3,6 +3,7 @@ import uuid import random import string import json +import requests # Django imports from django.utils import timezone @@ -85,6 +86,28 @@ class SignInEndpoint(BaseAPIView): "user": serialized_user, } + # Send Analytics + if settings.ANALYTICS_BASE_API: + _ = requests.post( + settings.ANALYTICS_BASE_API, + headers={ + "Content-Type": "application/json", + "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, + }, + json={ + "event_id": uuid.uuid4().hex, + "event_data": { + "medium": "email", + }, + "user": {"email": email, "id": str(user.id)}, + "device_ctx": { + "ip": request.META.get("REMOTE_ADDR"), + "user_agent": request.META.get("HTTP_USER_AGENT"), + }, + "event_type": "SIGN_UP", + }, + ) + return Response(data, status=status.HTTP_200_OK) # Sign in Process else: @@ -114,7 +137,27 @@ class SignInEndpoint(BaseAPIView): user.save() access_token, refresh_token = get_tokens_for_user(user) - + # Send Analytics + if settings.ANALYTICS_BASE_API: + _ = requests.post( + settings.ANALYTICS_BASE_API, + headers={ + "Content-Type": "application/json", + "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, + }, + json={ + "event_id": uuid.uuid4().hex, + "event_data": { + "medium": "email", + }, + "user": {"email": email, "id": str(user.id)}, + "device_ctx": { + "ip": request.META.get("REMOTE_ADDR"), + "user_agent": request.META.get("HTTP_USER_AGENT"), + }, + "event_type": "SIGN_IN", + }, + ) data = { "access_token": access_token, "refresh_token": refresh_token, @@ -268,6 +311,29 @@ class MagicSignInEndpoint(BaseAPIView): if str(token) == str(user_token): if User.objects.filter(email=email).exists(): user = User.objects.get(email=email) + # Send event to Jitsu for tracking + if settings.ANALYTICS_BASE_API: + _ = requests.post( + settings.ANALYTICS_BASE_API, + headers={ + "Content-Type": "application/json", + "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, + }, + json={ + "event_id": uuid.uuid4().hex, + "event_data": { + "medium": "code", + }, + "user": {"email": email, "id": str(user.id)}, + "device_ctx": { + "ip": request.META.get("REMOTE_ADDR"), + "user_agent": request.META.get( + "HTTP_USER_AGENT" + ), + }, + "event_type": "SIGN_IN", + }, + ) else: user = User.objects.create( email=email, @@ -275,6 +341,29 @@ class MagicSignInEndpoint(BaseAPIView): password=make_password(uuid.uuid4().hex), is_password_autoset=True, ) + # Send event to Jitsu for tracking + if settings.ANALYTICS_BASE_API: + _ = requests.post( + settings.ANALYTICS_BASE_API, + headers={ + "Content-Type": "application/json", + "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, + }, + json={ + "event_id": uuid.uuid4().hex, + "event_data": { + "medium": "code", + }, + "user": {"email": email, "id": str(user.id)}, + "device_ctx": { + "ip": request.META.get("REMOTE_ADDR"), + "user_agent": request.META.get( + "HTTP_USER_AGENT" + ), + }, + "event_type": "SIGN_UP", + }, + ) user.last_active = timezone.now() user.last_login_time = timezone.now() diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 9a1f40a6d..73edb7d1e 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -3,9 +3,11 @@ import json # Django imports from django.db import IntegrityError -from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef +from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef, Count, Prefetch from django.core import serializers from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page # Third party imports from rest_framework.response import Response @@ -18,11 +20,18 @@ from plane.api.serializers import ( CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, + IssueStateSerializer, ) from plane.api.permissions import ProjectEntityPermission -from plane.db.models import Cycle, CycleIssue, Issue, CycleFavorite +from plane.db.models import ( + Cycle, + CycleIssue, + Issue, + CycleFavorite, +) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results +from plane.utils.issue_filters import issue_filters class CycleViewSet(BaseViewSet): @@ -38,6 +47,12 @@ class CycleViewSet(BaseViewSet): ) def get_queryset(self): + subquery = CycleFavorite.objects.filter( + user=self.request.user, + cycle_id=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) return self.filter_queryset( super() .get_queryset() @@ -47,26 +62,42 @@ class CycleViewSet(BaseViewSet): .select_related("project") .select_related("workspace") .select_related("owned_by") + .annotate(is_favorite=Exists(subquery)) + .annotate(total_issues=Count("issue_cycle")) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="completed"), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="cancelled"), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="started"), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="unstarted"), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="backlog"), + ) + ) + .order_by("-is_favorite", "name") .distinct() ) - def list(self, request, slug, project_id): - try: - subquery = CycleFavorite.objects.filter( - user=self.request.user, - cycle_id=OuterRef("pk"), - project_id=project_id, - workspace__slug=slug, - ) - cycles = self.get_queryset().annotate(is_favorite=Exists(subquery)) - return Response(CycleSerializer(cycles, many=True).data) - except Exception as e: - capture_exception(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 ( @@ -98,6 +129,36 @@ class CycleViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + def partial_update(self, request, slug, project_id, pk): + try: + cycle = Cycle.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + + if cycle.end_date is not None and cycle.end_date < timezone.now().date(): + return Response( + { + "error": "The Cycle has already been completed so it cannot be edited" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = CycleSerializer(cycle, 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 Cycle.DoesNotExist: + return Response( + {"error": "Cycle 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 CycleIssueViewSet(BaseViewSet): serializer_class = CycleIssueSerializer @@ -140,22 +201,43 @@ class CycleIssueViewSet(BaseViewSet): .distinct() ) + @method_decorator(gzip_page) def list(self, request, slug, project_id, cycle_id): try: order_by = request.GET.get("order_by", "created_at") - queryset = self.get_queryset().order_by(f"issue__{order_by}") group_by = request.GET.get("group_by", False) + filters = issue_filters(request.query_params, "GET") + issues = ( + Issue.objects.filter(issue_cycle__cycle_id=cycle_id) + .annotate( + sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate(bridge_id=F("issue_cycle__id")) + .filter(project_id=project_id) + .filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(order_by) + .filter(**filters) + ) - cycle_issues = CycleIssueSerializer(queryset, many=True).data + issues_data = IssueStateSerializer(issues, many=True).data if group_by: return Response( - group_results(cycle_issues, f"issue_detail.{group_by}"), + group_results(issues_data, group_by), status=status.HTTP_200_OK, ) return Response( - cycle_issues, + issues_data, status=status.HTTP_200_OK, ) except Exception as e: @@ -178,6 +260,14 @@ class CycleIssueViewSet(BaseViewSet): workspace__slug=slug, project_id=project_id, pk=cycle_id ) + if cycle.end_date is not None and cycle.end_date < timezone.now().date(): + return Response( + { + "error": "The Cycle has already been completed so no new issues can be added" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # Get all CycleIssues already created cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues)) records_to_update = [] @@ -263,10 +353,20 @@ class CycleIssueViewSet(BaseViewSet): class CycleDateCheckEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + def post(self, request, slug, project_id): try: - start_date = request.data.get("start_date") - end_date = request.data.get("end_date") + start_date = request.data.get("start_date", False) + end_date = request.data.get("end_date", False) + + if not start_date or not end_date: + return Response( + {"error": "Start date and end date both are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) cycles = Cycle.objects.filter( Q(start_date__lte=start_date, end_date__gte=start_date) @@ -294,6 +394,10 @@ class CycleDateCheckEndpoint(BaseAPIView): class CurrentUpcomingCyclesEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + def get(self, request, slug, project_id): try: subquery = CycleFavorite.objects.filter( @@ -302,18 +406,94 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView): project_id=project_id, workspace__slug=slug, ) - current_cycle = Cycle.objects.filter( - workspace__slug=slug, - project_id=project_id, - start_date__lte=timezone.now(), - end_date__gte=timezone.now(), - ).annotate(is_favorite=Exists(subquery)) + current_cycle = ( + Cycle.objects.filter( + workspace__slug=slug, + project_id=project_id, + start_date__lte=timezone.now(), + end_date__gte=timezone.now(), + ) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate(is_favorite=Exists(subquery)) + .annotate(total_issues=Count("issue_cycle")) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="completed"), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="cancelled"), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="started"), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="unstarted"), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="backlog"), + ) + ) + .order_by("name", "-is_favorite") + ) - upcoming_cycle = Cycle.objects.filter( - workspace__slug=slug, - project_id=project_id, - start_date__gt=timezone.now(), - ).annotate(is_favorite=Exists(subquery)) + upcoming_cycle = ( + Cycle.objects.filter( + workspace__slug=slug, + project_id=project_id, + start_date__gt=timezone.now(), + ) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate(is_favorite=Exists(subquery)) + .annotate(total_issues=Count("issue_cycle")) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="completed"), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="cancelled"), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="started"), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="unstarted"), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="backlog"), + ) + ) + .order_by("name", "-is_favorite") + ) return Response( { @@ -332,6 +512,10 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView): class CompletedCyclesEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + def get(self, request, slug, project_id): try: subquery = CycleFavorite.objects.filter( @@ -340,11 +524,49 @@ class CompletedCyclesEndpoint(BaseAPIView): project_id=project_id, workspace__slug=slug, ) - completed_cycles = Cycle.objects.filter( - workspace__slug=slug, - project_id=project_id, - end_date__lt=timezone.now(), - ).annotate(is_favorite=Exists(subquery)) + completed_cycles = ( + Cycle.objects.filter( + workspace__slug=slug, + project_id=project_id, + end_date__lt=timezone.now(), + ) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate(is_favorite=Exists(subquery)) + .annotate(total_issues=Count("issue_cycle")) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="completed"), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="cancelled"), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="started"), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="unstarted"), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="backlog"), + ) + ) + .order_by("name", "-is_favorite") + ) return Response( { @@ -364,13 +586,61 @@ class CompletedCyclesEndpoint(BaseAPIView): class DraftCyclesEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + def get(self, request, slug, project_id): try: - draft_cycles = Cycle.objects.filter( - workspace__slug=slug, + subquery = CycleFavorite.objects.filter( + user=self.request.user, + cycle_id=OuterRef("pk"), project_id=project_id, - end_date=None, - start_date=None, + workspace__slug=slug, + ) + draft_cycles = ( + Cycle.objects.filter( + workspace__slug=slug, + project_id=project_id, + end_date=None, + start_date=None, + ) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate(is_favorite=Exists(subquery)) + .annotate(total_issues=Count("issue_cycle")) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="completed"), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="cancelled"), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="started"), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="unstarted"), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q(issue_cycle__issue__state__group="backlog"), + ) + ) + .order_by("name", "-is_favorite") ) return Response( @@ -386,6 +656,10 @@ class DraftCyclesEndpoint(BaseAPIView): class CycleFavoriteViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + serializer_class = CycleFavoriteSerializer model = CycleFavorite @@ -445,3 +719,82 @@ class CycleFavoriteViewSet(BaseViewSet): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class TransferCycleIssueEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id, cycle_id): + try: + new_cycle_id = request.data.get("new_cycle_id", False) + + if not new_cycle_id: + return Response( + {"error": "New Cycle Id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + new_cycle = Cycle.objects.get( + workspace__slug=slug, project_id=project_id, pk=new_cycle_id + ) + + if ( + new_cycle.end_date is not None + and new_cycle.end_date < timezone.now().date() + ): + return Response( + { + "error": "The cycle where the issues are transferred is already completed" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + cycle_issues = CycleIssue.objects.filter( + cycle_id=cycle_id, + project_id=project_id, + workspace__slug=slug, + issue__state__group__in=["backlog", "unstarted", "started"], + ) + + updated_cycles = [] + for cycle_issue in cycle_issues: + cycle_issue.cycle_id = new_cycle_id + updated_cycles.append(cycle_issue) + + cycle_issues = CycleIssue.objects.bulk_update( + updated_cycles, ["cycle_id"], batch_size=100 + ) + + return Response({"message": "Success"}, status=status.HTTP_200_OK) + except Cycle.DoesNotExist: + return Response( + {"error": "New Cycle 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 InCompleteCyclesEndpoint(BaseAPIView): + def get(self, request, slug, project_id): + try: + cycles = Cycle.objects.filter( + Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True), + workspace__slug=slug, + project_id=project_id, + ).select_related("owned_by") + + serializer = CycleSerializer(cycles, many=True) + return Response(serializer.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/gpt.py b/apiserver/plane/api/views/gpt.py new file mode 100644 index 000000000..a48bea242 --- /dev/null +++ b/apiserver/plane/api/views/gpt.py @@ -0,0 +1,101 @@ +# Python imports +import requests + +# Third party imports +from rest_framework.response import Response +from rest_framework import status +import openai +from sentry_sdk import capture_exception + +# Django imports +from django.conf import settings + +# Module imports +from .base import BaseAPIView +from plane.api.permissions import ProjectEntityPermission +from plane.db.models import Workspace, Project +from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer + + +class GPTIntegrationEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id): + try: + if not settings.OPENAI_API_KEY or not settings.GPT_ENGINE: + return Response( + {"error": "OpenAI API key and engine is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + count = 0 + + # If logger is enabled check for request limit + if settings.LOGGER_BASE_URL: + try: + headers = { + "Content-Type": "application/json", + } + + response = requests.post( + settings.LOGGER_BASE_URL, + json={"user_id": str(request.user.id)}, + headers=headers, + ) + count = response.json().get("count", 0) + if not response.json().get("success", False): + return Response( + { + "error": "You have surpassed the monthly limit for AI assistance" + }, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + except Exception as e: + capture_exception(e) + + prompt = request.data.get("prompt", False) + task = request.data.get("task", False) + + if not task: + return Response( + {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + final_text = task + "\n" + prompt + + openai.api_key = settings.OPENAI_API_KEY + response = openai.Completion.create( + engine=settings.GPT_ENGINE, + prompt=final_text, + temperature=0.7, + max_tokens=1024, + ) + + workspace = Workspace.objects.get(slug=slug) + project = Project.objects.get(pk=project_id) + + text = response.choices[0].text.strip() + text_html = text.replace("\n", "
") + return Response( + { + "response": text, + "response_html": text_html, + "count": count, + "project_detail": ProjectLiteSerializer(project).data, + "workspace_detail": WorkspaceLiteSerializer(workspace).data, + }, + status=status.HTTP_200_OK, + ) + except (Workspace.DoesNotExist, Project.DoesNotExist) as e: + return Response( + {"error": "Workspace or Project 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/importer.py b/apiserver/plane/api/views/importer.py new file mode 100644 index 000000000..dd52d2dd2 --- /dev/null +++ b/apiserver/plane/api/views/importer.py @@ -0,0 +1,519 @@ +# Python imports +import uuid + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from sentry_sdk import capture_exception + +# Django imports +from django.db.models import Max + +# Module imports +from plane.api.views import BaseAPIView +from plane.db.models import ( + WorkspaceIntegration, + Importer, + APIToken, + Project, + State, + IssueSequence, + Issue, + IssueActivity, + IssueComment, + IssueLink, + IssueLabel, + Workspace, + IssueAssignee, + Module, + ModuleLink, + ModuleIssue, +) +from plane.api.serializers import ( + ImporterSerializer, + IssueFlatSerializer, + ModuleSerializer, +) +from plane.utils.integrations.github import get_github_repo_details +from plane.utils.importers.jira import jira_project_issue_summary +from plane.bgtasks.importer_task import service_importer +from plane.utils.html_processor import strip_tags + + +class ServiceIssueImportSummaryEndpoint(BaseAPIView): + def get(self, request, slug, service): + try: + if service == "github": + workspace_integration = WorkspaceIntegration.objects.get( + integration__provider="github", workspace__slug=slug + ) + + access_tokens_url = workspace_integration.metadata["access_tokens_url"] + owner = request.GET.get("owner") + repo = request.GET.get("repo") + + issue_count, labels, collaborators = get_github_repo_details( + access_tokens_url, owner, repo + ) + return Response( + { + "issue_count": issue_count, + "labels": labels, + "collaborators": collaborators, + }, + status=status.HTTP_200_OK, + ) + + 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, + ) + + return Response( + jira_project_issue_summary( + email, api_token, project_name, cloud_hostname + ), + status=status.HTTP_200_OK, + ) + return Response( + {"error": "Service not supported yet"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except WorkspaceIntegration.DoesNotExist: + return Response( + {"error": "Requested integration was not installed in the workspace"}, + 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, + ) + + +class ImportServiceEndpoint(BaseAPIView): + def post(self, request, slug, service): + try: + project_id = request.data.get("project_id", False) + + if not project_id: + return Response( + {"error": "Project ID is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = Workspace.objects.get(slug=slug) + + if service == "github": + data = request.data.get("data", False) + metadata = request.data.get("metadata", False) + config = request.data.get("config", False) + if not data or not metadata or not config: + return Response( + {"error": "Data, config and metadata are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + api_token = APIToken.objects.filter( + user=request.user, workspace=workspace + ).first() + if api_token is None: + api_token = APIToken.objects.create( + user=request.user, + label="Importer", + workspace=workspace, + ) + + importer = Importer.objects.create( + service=service, + project_id=project_id, + status="queued", + initiated_by=request.user, + data=data, + metadata=metadata, + token=api_token, + config=config, + created_by=request.user, + updated_by=request.user, + ) + + service_importer.delay(service, importer.id) + serializer = ImporterSerializer(importer) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + if service == "jira": + data = request.data.get("data", False) + metadata = request.data.get("metadata", False) + config = request.data.get("config", False) + if not data or not metadata: + return Response( + {"error": "Data, config and metadata are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + api_token = APIToken.objects.filter( + user=request.user, workspace=workspace + ).first() + if api_token is None: + api_token = APIToken.objects.create( + user=request.user, + label="Importer", + workspace=workspace, + ) + + importer = Importer.objects.create( + service=service, + project_id=project_id, + status="queued", + initiated_by=request.user, + data=data, + metadata=metadata, + token=api_token, + config=config, + created_by=request.user, + updated_by=request.user, + ) + + service_importer.delay(service, importer.id) + serializer = ImporterSerializer(importer) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response( + {"error": "Servivce not supported yet"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except ( + Workspace.DoesNotExist, + WorkspaceIntegration.DoesNotExist, + Project.DoesNotExist, + ) as e: + return Response( + {"error": "Workspace Integration or Project does not exist"}, + 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, + ) + + def get(self, request, slug): + try: + imports = Importer.objects.filter(workspace__slug=slug).order_by( + "-created_at" + ) + serializer = ImporterSerializer(imports, many=True) + return Response(serializer.data) + 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): + try: + importer = Importer.objects.get( + pk=importer_id, + workspace__slug=slug, + project_id=project_id, + service=service, + ) + importer.status = request.data.get("status", "processing") + importer.save() + return Response(status.HTTP_200_OK) + except Importer.DoesNotExist: + return Response( + {"error": "Importer does not exist"}, status=status.HTTP_404_NOT_FOUND + ) + + +class BulkImportIssuesEndpoint(BaseAPIView): + def post(self, request, slug, project_id, service): + try: + # Get the project + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + # Get the default state + default_state = State.objects.filter( + project_id=project_id, default=True + ).first() + # if there is no default state assign any random state + if default_state is None: + default_state = State.objects.filter(project_id=project_id).first() + + # Get the maximum sequence_id + last_id = IssueSequence.objects.filter(project_id=project_id).aggregate( + largest=Max("sequence") + )["largest"] + + last_id = 1 if last_id is None else last_id + 1 + + # Get the maximum sort order + largest_sort_order = Issue.objects.filter( + project_id=project_id, state=default_state + ).aggregate(largest=Max("sort_order"))["largest"] + + largest_sort_order = ( + 65535 if largest_sort_order is None else largest_sort_order + 10000 + ) + + # Get the issues_data + issues_data = request.data.get("issues_data", []) + + if not len(issues_data): + return Response( + {"error": "Issue data is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Issues + bulk_issues = [] + for issue_data in issues_data: + bulk_issues.append( + Issue( + project_id=project_id, + workspace_id=project.workspace_id, + state_id=issue_data.get("state") + if issue_data.get("state", False) + else default_state.id, + name=issue_data.get("name", "Issue Created through Bulk"), + description_html=issue_data.get("description_html", "

"), + description_stripped=( + None + if ( + issue_data.get("description_html") == "" + or issue_data.get("description_html") is None + ) + else strip_tags(issue_data.get("description_html")) + ), + sequence_id=last_id, + sort_order=largest_sort_order, + start_date=issue_data.get("start_date", None), + target_date=issue_data.get("target_date", None), + priority=issue_data.get("priority", None), + ) + ) + + largest_sort_order = largest_sort_order + 10000 + last_id = last_id + 1 + + issues = Issue.objects.bulk_create( + bulk_issues, + batch_size=100, + ignore_conflicts=True, + ) + + # Sequences + _ = IssueSequence.objects.bulk_create( + [ + IssueSequence( + issue=issue, + sequence=issue.sequence_id, + project_id=project_id, + workspace_id=project.workspace_id, + ) + for issue in issues + ], + batch_size=100, + ) + + # Attach Labels + bulk_issue_labels = [] + for issue, issue_data in zip(issues, issues_data): + labels_list = issue_data.get("labels_list", []) + bulk_issue_labels = bulk_issue_labels + [ + IssueLabel( + issue=issue, + label_id=label_id, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for label_id in labels_list + ] + + _ = IssueLabel.objects.bulk_create( + bulk_issue_labels, batch_size=100, ignore_conflicts=True + ) + + # Attach Assignees + bulk_issue_assignees = [] + for issue, issue_data in zip(issues, issues_data): + assignees_list = issue_data.get("assignees_list", []) + bulk_issue_assignees = bulk_issue_assignees + [ + IssueAssignee( + issue=issue, + assignee_id=assignee_id, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for assignee_id in assignees_list + ] + + _ = IssueAssignee.objects.bulk_create( + bulk_issue_assignees, batch_size=100, ignore_conflicts=True + ) + + # Track the issue activities + IssueActivity.objects.bulk_create( + [ + IssueActivity( + issue=issue, + actor=request.user, + project_id=project_id, + workspace_id=project.workspace_id, + comment=f"{request.user.email} importer the issue from {service}", + verb="created", + ) + for issue in issues + ], + batch_size=100, + ) + + # Create Comments + bulk_issue_comments = [] + for issue, issue_data in zip(issues, issues_data): + comments_list = issue_data.get("comments_list", []) + bulk_issue_comments = bulk_issue_comments + [ + IssueComment( + issue=issue, + comment_html=comment.get("comment_html", "

"), + actor=request.user, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for comment in comments_list + ] + + _ = IssueComment.objects.bulk_create(bulk_issue_comments, batch_size=100) + + # Attach Links + _ = IssueLink.objects.bulk_create( + [ + IssueLink( + issue=issue, + url=issue_data.get("link", {}).get("url", "https://github.com"), + title=issue_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 issue, issue_data in zip(issues, issues_data) + ] + ) + + return Response( + {"issues": IssueFlatSerializer(issues, many=True).data}, + status=status.HTTP_201_CREATED, + ) + except Project.DoesNotExist: + return Response( + {"error": "Project Does not exist"}, 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 BulkImportModulesEndpoint(BaseAPIView): + def post(self, request, slug, project_id, service): + try: + modules_data = request.data.get("modules_data", []) + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + modules = Module.objects.bulk_create( + [ + Module( + name=module.get("name", uuid.uuid4().hex), + description=module.get("description", ""), + start_date=module.get("start_date", None), + target_date=module.get("target_date", None), + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for module in modules_data + ], + batch_size=100, + 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, + ) + + 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 + ] + + _ = 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 + ) + except Project.DoesNotExist: + return Response( + {"error": "Project does not exist"}, 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, + ) diff --git a/apiserver/plane/api/views/integration/__init__.py b/apiserver/plane/api/views/integration/__init__.py index 693202573..67dd370d9 100644 --- a/apiserver/plane/api/views/integration/__init__.py +++ b/apiserver/plane/api/views/integration/__init__.py @@ -2,6 +2,7 @@ from .base import IntegrationViewSet, WorkspaceIntegrationViewSet from .github import ( GithubRepositorySyncViewSet, GithubIssueSyncViewSet, + BulkCreateGithubIssueSyncEndpoint, GithubCommentSyncViewSet, GithubRepositoriesEndpoint, ) diff --git a/apiserver/plane/api/views/integration/base.py b/apiserver/plane/api/views/integration/base.py index 4f15c347f..8312afa01 100644 --- a/apiserver/plane/api/views/integration/base.py +++ b/apiserver/plane/api/views/integration/base.py @@ -25,7 +25,7 @@ from plane.utils.integrations.github import ( get_github_metadata, delete_github_installation, ) - +from plane.api.permissions import WorkSpaceAdminPermission class IntegrationViewSet(BaseViewSet): serializer_class = IntegrationSerializer @@ -75,11 +75,33 @@ class IntegrationViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + def destroy(self, request, pk): + try: + integration = Integration.objects.get(pk=pk) + if integration.verified: + return Response( + {"error": "Verified integrations cannot be updated"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + integration.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except Integration.DoesNotExist: + return Response( + {"error": "Integration Does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + class WorkspaceIntegrationViewSet(BaseViewSet): serializer_class = WorkspaceIntegrationSerializer model = WorkspaceIntegration + permission_classes = [ + WorkSpaceAdminPermission, + ] + + def get_queryset(self): return ( super() diff --git a/apiserver/plane/api/views/integration/github.py b/apiserver/plane/api/views/integration/github.py index 5660e9d90..4cf07c705 100644 --- a/apiserver/plane/api/views/integration/github.py +++ b/apiserver/plane/api/views/integration/github.py @@ -13,6 +13,7 @@ from plane.db.models import ( ProjectMember, Label, GithubCommentSync, + Project, ) from plane.api.serializers import ( GithubIssueSyncSerializer, @@ -20,15 +21,27 @@ from plane.api.serializers import ( GithubCommentSyncSerializer, ) from plane.utils.integrations.github import get_github_repos +from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission class GithubRepositoriesEndpoint(BaseAPIView): + permission_classes = [ + ProjectBasePermission, + ] + def get(self, request, slug, workspace_integration_id): try: page = request.GET.get("page", 1) workspace_integration = WorkspaceIntegration.objects.get( workspace__slug=slug, pk=workspace_integration_id ) + + if workspace_integration.integration.provider != "github": + return Response( + {"error": "Not a github integration"}, + status=status.HTTP_400_BAD_REQUEST, + ) + access_tokens_url = workspace_integration.metadata["access_tokens_url"] repositories_url = ( workspace_integration.metadata["repositories_url"] @@ -44,6 +57,10 @@ class GithubRepositoriesEndpoint(BaseAPIView): class GithubRepositorySyncViewSet(BaseViewSet): + permission_classes = [ + ProjectBasePermission, + ] + serializer_class = GithubRepositorySyncSerializer model = GithubRepositorySync @@ -84,10 +101,6 @@ class GithubRepositorySyncViewSet(BaseViewSet): GithubRepository.objects.filter( project_id=project_id, workspace__slug=slug ).delete() - # Project member delete - ProjectMember.objects.filter( - member=workspace_integration.actor, role=20, project_id=project_id - ).delete() # Create repository repo = GithubRepository.objects.create( @@ -124,7 +137,7 @@ class GithubRepositorySyncViewSet(BaseViewSet): ) # Add bot as a member in the project - _ = ProjectMember.objects.create( + _ = ProjectMember.objects.get_or_create( member=workspace_integration.actor, role=20, project_id=project_id ) @@ -148,6 +161,10 @@ class GithubRepositorySyncViewSet(BaseViewSet): class GithubIssueSyncViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + serializer_class = GithubIssueSyncSerializer model = GithubIssueSync @@ -158,7 +175,52 @@ class GithubIssueSyncViewSet(BaseViewSet): ) +class BulkCreateGithubIssueSyncEndpoint(BaseAPIView): + def post(self, request, slug, project_id, repo_sync_id): + try: + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + github_issue_syncs = request.data.get("github_issue_syncs", []) + github_issue_syncs = GithubIssueSync.objects.bulk_create( + [ + GithubIssueSync( + issue_id=github_issue_sync.get("issue"), + repo_issue_id=github_issue_sync.get("repo_issue_id"), + issue_url=github_issue_sync.get("issue_url"), + github_issue_id=github_issue_sync.get("github_issue_id"), + repository_sync_id=repo_sync_id, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for github_issue_sync in github_issue_syncs + ], + batch_size=100, + ignore_conflicts=True, + ) + + serializer = GithubIssueSyncSerializer(github_issue_syncs, many=True) + return Response(serializer.data, status=status.HTTP_201_CREATED) + except Project.DoesNotExist: + return Response( + {"error": "Project does not exist"}, + 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 GithubCommentSyncViewSet(BaseViewSet): + + permission_classes = [ + ProjectEntityPermission, + ] + serializer_class = GithubCommentSyncSerializer model = GithubCommentSync diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index ca40606ec..d22c65092 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -1,10 +1,13 @@ # Python imports import json +import random from itertools import groupby, chain # Django imports from django.db.models import Prefetch, OuterRef, Func, F, Q from django.core.serializers.json import DjangoJSONEncoder +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page # Third Party imports from rest_framework.response import Response @@ -24,6 +27,7 @@ from plane.api.serializers import ( LabelSerializer, IssueFlatSerializer, IssueLinkSerializer, + IssueLiteSerializer, ) from plane.api.permissions import ( ProjectEntityPermission, @@ -38,13 +42,11 @@ from plane.db.models import ( TimelineIssue, IssueProperty, Label, - IssueBlocker, - CycleIssue, - ModuleIssue, IssueLink, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results +from plane.utils.issue_filters import issue_filters class IssueViewSet(BaseViewSet): @@ -133,59 +135,29 @@ class IssueViewSet(BaseViewSet): .select_related("parent") .prefetch_related("assignees") .prefetch_related("labels") - .prefetch_related( - Prefetch( - "blocked_issues", - queryset=IssueBlocker.objects.select_related("blocked_by", "block"), - ) - ) - .prefetch_related( - Prefetch( - "blocker_issues", - queryset=IssueBlocker.objects.select_related("block", "blocked_by"), - ) - ) - .prefetch_related( - Prefetch( - "issue_cycle", - queryset=CycleIssue.objects.select_related("cycle", "issue"), - ), - ) - .prefetch_related( - Prefetch( - "issue_module", - queryset=ModuleIssue.objects.select_related( - "module", "issue" - ).prefetch_related("module__members"), - ), - ) - .prefetch_related( - Prefetch( - "issue_link", - queryset=IssueLink.objects.select_related("issue").select_related( - "created_by" - ), - ) - ) ) + @method_decorator(gzip_page) def list(self, request, slug, project_id): try: - # Issue State groups - type = request.GET.get("type", "all") - group = ["backlog", "unstarted", "started", "completed", "cancelled"] - if type == "backlog": - group = ["backlog"] - if type == "active": - group = ["unstarted", "started"] + filters = issue_filters(request.query_params, "GET") + show_sub_issues = request.GET.get("show_sub_issues", "true") issue_queryset = ( self.get_queryset() .order_by(request.GET.get("order_by", "created_at")) - .filter(state__group__in=group) + .filter(**filters) + .annotate(cycle_id=F("issue_cycle__id")) + .annotate(module_id=F("issue_module__id")) ) - issues = IssueSerializer(issue_queryset, many=True).data + issue_queryset = ( + issue_queryset + if show_sub_issues == "true" + else issue_queryset.filter(parent__isnull=True) + ) + + issues = IssueLiteSerializer(issue_queryset, many=True).data ## Grouping the results group_by = request.GET.get("group_by", False) @@ -197,7 +169,6 @@ class IssueViewSet(BaseViewSet): return Response(issues, status=status.HTTP_200_OK) except Exception as e: - print(e) capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, @@ -235,8 +206,20 @@ class IssueViewSet(BaseViewSet): {"error": "Project was not found"}, status=status.HTTP_404_NOT_FOUND ) + def retrieve(self, request, slug, project_id, pk=None): + try: + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) + except Issue.DoesNotExist: + return Response( + {"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND + ) + class UserWorkSpaceIssues(BaseAPIView): + @method_decorator(gzip_page) def get(self, request, slug): try: issues = ( @@ -253,44 +236,9 @@ class UserWorkSpaceIssues(BaseAPIView): .select_related("parent") .prefetch_related("assignees") .prefetch_related("labels") - .prefetch_related( - Prefetch( - "blocked_issues", - queryset=IssueBlocker.objects.select_related( - "blocked_by", "block" - ), - ) - ) - .prefetch_related( - Prefetch( - "blocker_issues", - queryset=IssueBlocker.objects.select_related( - "block", "blocked_by" - ), - ) - ) - .prefetch_related( - Prefetch( - "issue_cycle", - queryset=CycleIssue.objects.select_related("cycle", "issue"), - ), - ) - .prefetch_related( - Prefetch( - "issue_module", - queryset=ModuleIssue.objects.select_related("module", "issue"), - ), - ) - .prefetch_related( - Prefetch( - "issue_link", - queryset=IssueLink.objects.select_related( - "issue" - ).select_related("created_by"), - ) - ) + .order_by("-created_at") ) - serializer = IssueSerializer(issues, many=True) + serializer = IssueLiteSerializer(issues, many=True) return Response(serializer.data, status=status.HTTP_200_OK) except Exception as e: capture_exception(e) @@ -305,10 +253,13 @@ class WorkSpaceIssuesEndpoint(BaseAPIView): WorkSpaceAdminPermission, ] + @method_decorator(gzip_page) def get(self, request, slug): try: - issues = Issue.objects.filter(workspace__slug=slug).filter( - project__project_projectmember__member=self.request.user + issues = ( + Issue.objects.filter(workspace__slug=slug) + .filter(project__project_projectmember__member=self.request.user) + .order_by("-created_at") ) serializer = IssueSerializer(issues, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -325,6 +276,7 @@ class IssueActivityEndpoint(BaseAPIView): ProjectEntityPermission, ] + @method_decorator(gzip_page) def get(self, request, slug, project_id, issue_id): try: issue_activities = ( @@ -333,8 +285,8 @@ class IssueActivityEndpoint(BaseAPIView): ~Q(field="comment"), project__project_projectmember__member=self.request.user, ) - .select_related("actor") - ).order_by("created_by") + .select_related("actor", "workspace") + ).order_by("created_at") issue_comments = ( IssueComment.objects.filter(issue_id=issue_id) .filter(project__project_projectmember__member=self.request.user) @@ -561,6 +513,7 @@ class LabelViewSet(BaseViewSet): .select_related("project") .select_related("workspace") .select_related("parent") + .order_by("name") .distinct() ) @@ -605,6 +558,7 @@ class SubIssuesEndpoint(BaseAPIView): ProjectEntityPermission, ] + @method_decorator(gzip_page) def get(self, request, slug, project_id, issue_id): try: sub_issues = ( @@ -617,37 +571,9 @@ class SubIssuesEndpoint(BaseAPIView): .select_related("parent") .prefetch_related("assignees") .prefetch_related("labels") - .prefetch_related( - Prefetch( - "blocked_issues", - queryset=IssueBlocker.objects.select_related( - "blocked_by", "block" - ), - ) - ) - .prefetch_related( - Prefetch( - "blocker_issues", - queryset=IssueBlocker.objects.select_related( - "block", "blocked_by" - ), - ) - ) - .prefetch_related( - Prefetch( - "issue_cycle", - queryset=CycleIssue.objects.select_related("cycle", "issue"), - ), - ) - .prefetch_related( - Prefetch( - "issue_module", - queryset=ModuleIssue.objects.select_related("module", "issue"), - ), - ) ) - serializer = IssueSerializer(sub_issues, many=True) + serializer = IssueLiteSerializer(sub_issues, many=True) return Response(serializer.data, status=status.HTTP_200_OK) except Exception as e: capture_exception(e) @@ -715,5 +641,45 @@ class IssueLinkViewSet(BaseViewSet): .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) .filter(project__project_projectmember__member=self.request.user) + .order_by("-created_at") .distinct() ) + + +class BulkCreateIssueLabelsEndpoint(BaseAPIView): + def post(self, request, slug, project_id): + try: + label_data = request.data.get("label_data", []) + project = Project.objects.get(pk=project_id) + + labels = Label.objects.bulk_create( + [ + Label( + name=label.get("name", "Migrated"), + description=label.get("description", "Migrated Issue"), + color="#" + "%06x" % random.randint(0, 0xFFFFFF), + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for label in label_data + ], + batch_size=50, + ignore_conflicts=True, + ) + + return Response( + {"labels": LabelSerializer(labels, many=True).data}, + status=status.HTTP_201_CREATED, + ) + except Project.DoesNotExist: + return Response( + {"error": "Project Does not exist"}, 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, + ) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index ce74cfdff..3cdb54f70 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -3,8 +3,10 @@ import json # Django Imports from django.db import IntegrityError -from django.db.models import Prefetch, F, OuterRef, Func, Exists +from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q from django.core import serializers +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page # Third party imports from rest_framework.response import Response @@ -19,6 +21,7 @@ from plane.api.serializers import ( ModuleIssueSerializer, ModuleLinkSerializer, ModuleFavoriteSerializer, + IssueStateSerializer, ) from plane.api.permissions import ProjectEntityPermission from plane.db.models import ( @@ -31,6 +34,7 @@ from plane.db.models import ( ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results +from plane.utils.issue_filters import issue_filters class ModuleViewSet(BaseViewSet): @@ -47,29 +51,60 @@ class ModuleViewSet(BaseViewSet): ) def get_queryset(self): + subquery = ModuleFavorite.objects.filter( + user=self.request.user, + module_id=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) return ( super() .get_queryset() .filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) + .annotate(is_favorite=Exists(subquery)) .select_related("project") .select_related("workspace") .select_related("lead") .prefetch_related("members") - .prefetch_related( - Prefetch( - "issue_module", - queryset=ModuleIssue.objects.select_related( - "module", "issue", "issue__state", "issue__project" - ).prefetch_related("issue__assignees", "issue__labels"), - ) - ) .prefetch_related( Prefetch( "link_module", queryset=ModuleLink.objects.select_related("module", "created_by"), ) ) + .annotate(total_issues=Count("issue_module")) + .annotate( + completed_issues=Count( + "issue_module__issue__state__group", + filter=Q(issue_module__issue__state__group="completed"), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_module__issue__state__group", + filter=Q(issue_module__issue__state__group="cancelled"), + ) + ) + .annotate( + started_issues=Count( + "issue_module__issue__state__group", + filter=Q(issue_module__issue__state__group="started"), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_module__issue__state__group", + filter=Q(issue_module__issue__state__group="unstarted"), + ) + ) + .annotate( + backlog_issues=Count( + "issue_module__issue__state__group", + filter=Q(issue_module__issue__state__group="backlog"), + ) + ) + .order_by("-is_favorite", "name") ) def create(self, request, slug, project_id): @@ -101,23 +136,6 @@ class ModuleViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - def list(self, request, slug, project_id): - try: - subquery = ModuleFavorite.objects.filter( - user=self.request.user, - module_id=OuterRef("pk"), - project_id=project_id, - workspace__slug=slug, - ) - modules = self.get_queryset().annotate(is_favorite=Exists(subquery)) - return Response(ModuleSerializer(modules, many=True).data) - except Exception as e: - capture_exception(e) - return Response( - {"error": "Something went wrong please try again later"}, - status=status.HTTP_400_BAD_REQUEST, - ) - class ModuleIssueViewSet(BaseViewSet): serializer_class = ModuleIssueSerializer @@ -161,22 +179,43 @@ class ModuleIssueViewSet(BaseViewSet): .distinct() ) + @method_decorator(gzip_page) def list(self, request, slug, project_id, module_id): try: - order_by = request.GET.get("order_by", "issue__created_at") - queryset = self.get_queryset().order_by(f"issue__{order_by}") + order_by = request.GET.get("order_by", "created_at") group_by = request.GET.get("group_by", False) + filters = issue_filters(request.query_params, "GET") + issues = ( + Issue.objects.filter(issue_module__module_id=module_id) + .annotate( + sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate(bridge_id=F("issue_module__id")) + .filter(project_id=project_id) + .filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(order_by) + .filter(**filters) + ) - module_issues = ModuleIssueSerializer(queryset, many=True).data + issues_data = IssueStateSerializer(issues, many=True).data if group_by: return Response( - group_results(module_issues, f"issue_detail.{group_by}"), + group_results(issues_data, group_by), status=status.HTTP_200_OK, ) return Response( - module_issues, + issues_data, status=status.HTTP_200_OK, ) except Exception as e: @@ -302,11 +341,16 @@ class ModuleLinkViewSet(BaseViewSet): .filter(project_id=self.kwargs.get("project_id")) .filter(module_id=self.kwargs.get("module_id")) .filter(project__project_projectmember__member=self.request.user) + .order_by("-created_at") .distinct() ) class ModuleFavoriteViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + serializer_class = ModuleFavoriteSerializer model = ModuleFavorite diff --git a/apiserver/plane/api/views/oauth.py b/apiserver/plane/api/views/oauth.py index 994cb0466..650a8cc96 100644 --- a/apiserver/plane/api/views/oauth.py +++ b/apiserver/plane/api/views/oauth.py @@ -5,6 +5,7 @@ import os # Django imports from django.utils import timezone +from django.conf import settings # Third Party modules from rest_framework.response import Response @@ -204,7 +205,26 @@ class OauthEndpoint(BaseAPIView): "last_login_at": timezone.now(), }, ) - + if settings.ANALYTICS_BASE_API: + _ = requests.post( + settings.ANALYTICS_BASE_API, + headers={ + "Content-Type": "application/json", + "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, + }, + json={ + "event_id": uuid.uuid4().hex, + "event_data": { + "medium": f"oauth-{medium}", + }, + "user": {"email": email, "id": str(user.id)}, + "device_ctx": { + "ip": request.META.get("REMOTE_ADDR"), + "user_agent": request.META.get("HTTP_USER_AGENT"), + }, + "event_type": "SIGN_IN", + }, + ) return Response(data, status=status.HTTP_200_OK) except User.DoesNotExist: @@ -253,6 +273,26 @@ class OauthEndpoint(BaseAPIView): "user": serialized_user, "permissions": [], } + if settings.ANALYTICS_BASE_API: + _ = requests.post( + settings.ANALYTICS_BASE_API, + headers={ + "Content-Type": "application/json", + "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, + }, + json={ + "event_id": uuid.uuid4().hex, + "event_data": { + "medium": f"oauth-{medium}", + }, + "user": {"email": email, "id": str(user.id)}, + "device_ctx": { + "ip": request.META.get("REMOTE_ADDR"), + "user_agent": request.META.get("HTTP_USER_AGENT"), + }, + "event_type": "SIGN_UP", + }, + ) SocialLoginConnection.objects.update_or_create( medium=medium, diff --git a/apiserver/plane/api/views/page.py b/apiserver/plane/api/views/page.py new file mode 100644 index 000000000..b3f5b2dd5 --- /dev/null +++ b/apiserver/plane/api/views/page.py @@ -0,0 +1,487 @@ +# Python imports +from datetime import timedelta, datetime, date + +# Django imports +from django.db import IntegrityError +from django.db.models import Exists, OuterRef, Q, Prefetch +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from sentry_sdk import capture_exception + +# Module imports +from .base import BaseViewSet, BaseAPIView +from plane.api.permissions import ProjectEntityPermission +from plane.db.models import ( + Page, + PageBlock, + PageFavorite, + Issue, + IssueAssignee, + IssueActivity, +) +from plane.api.serializers import ( + PageSerializer, + PageBlockSerializer, + PageFavoriteSerializer, + IssueLiteSerializer, +) + + +class PageViewSet(BaseViewSet): + serializer_class = PageSerializer + model = Page + permission_classes = [ + ProjectEntityPermission, + ] + search_fields = [ + "name", + ] + + def get_queryset(self): + subquery = PageFavorite.objects.filter( + user=self.request.user, + page_id=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) + return self.filter_queryset( + 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(Q(owned_by=self.request.user) | Q(access=0)) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate(is_favorite=Exists(subquery)) + .order_by(self.request.GET.get("order_by", "-created_at")) + .prefetch_related("labels") + .order_by("name", "-is_favorite") + .prefetch_related( + Prefetch( + "blocks", + queryset=PageBlock.objects.select_related( + "page", "issue", "workspace", "project" + ), + ) + ) + .distinct() + ) + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), owned_by=self.request.user + ) + + def create(self, request, slug, project_id): + try: + serializer = PageSerializer( + data=request.data, + context={"project_id": project_id, "owned_by_id": request.user.id}, + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + 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 + model = PageBlock + permission_classes = [ + ProjectEntityPermission, + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(page_id=self.kwargs.get("page_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .select_related("page") + .select_related("issue") + .order_by("sort_order") + .distinct() + ) + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + page_id=self.kwargs.get("page_id"), + ) + + +class PageFavoriteViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + + serializer_class = PageFavoriteSerializer + model = PageFavorite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(user=self.request.user) + .select_related("page", "page__owned_by") + ) + + def create(self, request, slug, project_id): + try: + serializer = PageFavoriteSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(user=request.user, 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 page is already added to favorites"}, + 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, + ) + except Exception as 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, page_id): + try: + page_favorite = PageFavorite.objects.get( + project=project_id, + user=request.user, + workspace__slug=slug, + page_id=page_id, + ) + page_favorite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except PageFavorite.DoesNotExist: + return Response( + {"error": "Page is not in favorites"}, + 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 CreateIssueFromPageBlockEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id, page_id, page_block_id): + try: + page_block = PageBlock.objects.get( + pk=page_block_id, + workspace__slug=slug, + project_id=project_id, + page_id=page_id, + ) + issue = Issue.objects.create( + name=page_block.name, + project_id=project_id, + description=page_block.description, + description_html=page_block.description_html, + description_stripped=page_block.description_stripped, + ) + _ = IssueAssignee.objects.create( + issue=issue, assignee=request.user, project_id=project_id + ) + + _ = IssueActivity.objects.create( + issue=issue, + actor=request.user, + project_id=project_id, + comment=f"{request.user.email} created the issue from {page_block.name} block", + verb="created", + ) + + page_block.issue = issue + page_block.save() + + return Response(IssueLiteSerializer(issue).data, status=status.HTTP_200_OK) + except PageBlock.DoesNotExist: + return Response( + {"error": "Page Block does not exist"}, 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 RecentPagesEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def get(self, request, slug, project_id): + try: + subquery = PageFavorite.objects.filter( + user=request.user, + page_id=OuterRef("pk"), + project_id=project_id, + workspace__slug=slug, + ) + current_time = date.today() + day_before = current_time - timedelta(days=1) + + todays_pages = ( + Page.objects.filter( + updated_at__date=date.today(), + workspace__slug=slug, + project_id=project_id, + ) + .filter(project__project_projectmember__member=request.user) + .annotate(is_favorite=Exists(subquery)) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .prefetch_related("labels") + .prefetch_related( + Prefetch( + "blocks", + queryset=PageBlock.objects.select_related( + "page", "issue", "workspace", "project" + ), + ) + ) + .order_by("-is_favorite", "-updated_at") + ) + + yesterdays_pages = ( + Page.objects.filter( + updated_at__date=day_before, + workspace__slug=slug, + project_id=project_id, + ) + .filter(project__project_projectmember__member=request.user) + .annotate(is_favorite=Exists(subquery)) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .prefetch_related("labels") + .prefetch_related( + Prefetch( + "blocks", + queryset=PageBlock.objects.select_related( + "page", "issue", "workspace", "project" + ), + ) + ) + .order_by("-is_favorite", "-updated_at") + ) + + earlier_this_week = ( + Page.objects.filter( + updated_at__date__range=( + (timezone.now() - timedelta(days=7)), + (timezone.now() - timedelta(days=2)), + ), + workspace__slug=slug, + project_id=project_id, + ) + .annotate(is_favorite=Exists(subquery)) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .filter(project__project_projectmember__member=request.user) + .annotate(is_favorite=Exists(subquery)) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .prefetch_related("labels") + .prefetch_related( + Prefetch( + "blocks", + queryset=PageBlock.objects.select_related( + "page", "issue", "workspace", "project" + ), + ) + ) + .order_by("-is_favorite", "-updated_at") + ) + todays_pages_serializer = PageSerializer(todays_pages, many=True) + yesterday_pages_serializer = PageSerializer(yesterdays_pages, many=True) + earlier_this_week_serializer = PageSerializer(earlier_this_week, many=True) + return Response( + { + "today": todays_pages_serializer.data, + "yesterday": yesterday_pages_serializer.data, + "earlier_this_week": earlier_this_week_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, + ) + + +class FavoritePagesEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def get(self, request, slug, project_id): + try: + subquery = PageFavorite.objects.filter( + user=request.user, + page_id=OuterRef("pk"), + project_id=project_id, + workspace__slug=slug, + ) + pages = ( + Page.objects.filter( + workspace__slug=slug, + project_id=project_id, + ) + .annotate(is_favorite=Exists(subquery)) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .filter(project__project_projectmember__member=request.user) + .filter(is_favorite=True) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .prefetch_related("labels") + .prefetch_related( + Prefetch( + "blocks", + queryset=PageBlock.objects.select_related( + "page", "issue", "workspace", "project" + ), + ) + ) + .order_by("name", "-is_favorite") + ) + + serializer = PageSerializer(pages, many=True) + return Response(serializer.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, + ) + + +class MyPagesEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def get(self, request, slug, project_id): + try: + subquery = PageFavorite.objects.filter( + user=request.user, + page_id=OuterRef("pk"), + project_id=project_id, + workspace__slug=slug, + ) + pages = ( + Page.objects.filter( + workspace__slug=slug, project_id=project_id, owned_by=request.user + ) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .prefetch_related("labels") + .annotate(is_favorite=Exists(subquery)) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .filter(project__project_projectmember__member=request.user) + .prefetch_related( + Prefetch( + "blocks", + queryset=PageBlock.objects.select_related( + "page", "issue", "workspace", "project" + ), + ) + ) + .order_by("-is_favorite", "name") + ) + serializer = PageSerializer(pages, many=True) + return Response(serializer.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, + ) + + +class CreatedbyOtherPagesEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def get(self, request, slug, project_id): + try: + subquery = PageFavorite.objects.filter( + user=request.user, + page_id=OuterRef("pk"), + project_id=project_id, + workspace__slug=slug, + ) + pages = ( + Page.objects.filter( + ~Q(owned_by=request.user), + workspace__slug=slug, + project_id=project_id, + access=0, + ) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .prefetch_related("labels") + .annotate(is_favorite=Exists(subquery)) + .prefetch_related( + Prefetch( + "blocks", + queryset=PageBlock.objects.select_related( + "page", "issue", "workspace", "project" + ), + ) + ) + .order_by("-is_favorite", "name") + ) + serializer = PageSerializer(pages, many=True) + return Response(serializer.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/people.py b/apiserver/plane/api/views/people.py index 154888812..cafda3efd 100644 --- a/apiserver/plane/api/views/people.py +++ b/apiserver/plane/api/views/people.py @@ -7,10 +7,19 @@ from sentry_sdk import capture_exception # Module imports from plane.api.serializers import ( UserSerializer, + IssueActivitySerializer, ) from plane.api.views.base import BaseViewSet, BaseAPIView -from plane.db.models import User, Workspace +from plane.db.models import ( + User, + Workspace, + WorkspaceMemberInvite, + Issue, + IssueActivity, +) +from plane.utils.paginator import BasePaginator + class UserEndpoint(BaseViewSet): serializer_class = UserSerializer @@ -22,11 +31,34 @@ class UserEndpoint(BaseViewSet): def retrieve(self, request): try: workspace = Workspace.objects.get(pk=request.user.last_workspace_id) + workspace_invites = WorkspaceMemberInvite.objects.filter( + email=request.user.email + ).count() + assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count() + return Response( - {"user": UserSerializer(request.user).data, "slug": workspace.slug} + { + "user": UserSerializer(request.user).data, + "slug": workspace.slug, + "workspace_invites": workspace_invites, + "assigned_issues": assigned_issues, + }, + status=status.HTTP_200_OK, ) except Workspace.DoesNotExist: - return Response({"user": UserSerializer(request.user).data, "slug": None}) + workspace_invites = WorkspaceMemberInvite.objects.filter( + email=request.user.email + ).count() + assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count() + return Response( + { + "user": UserSerializer(request.user).data, + "slug": None, + "workspace_invites": workspace_invites, + "assigned_issues": assigned_issues, + }, + status=status.HTTP_200_OK, + ) except Exception as e: return Response( {"error": "Something went wrong please try again later"}, @@ -49,3 +81,25 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class UserActivityEndpoint(BaseAPIView, BasePaginator): + def get(self, request): + try: + queryset = IssueActivity.objects.filter(actor=request.user).select_related( + "actor", "workspace" + ) + + return self.paginate( + request=request, + queryset=queryset, + on_results=lambda issue_activities: IssueActivitySerializer( + issue_activities, many=True + ).data, + ) + 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/project.py b/apiserver/plane/api/views/project.py index 0e51dd156..869bd15c9 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -64,6 +64,11 @@ class ProjectViewSet(BaseViewSet): return ProjectDetailSerializer def get_queryset(self): + subquery = ProjectFavorite.objects.filter( + user=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + ) return self.filter_queryset( super() .get_queryset() @@ -72,6 +77,7 @@ class ProjectViewSet(BaseViewSet): .select_related( "workspace", "workspace__owner", "default_assignee", "project_lead" ) + .annotate(is_favorite=Exists(subquery)) .distinct() ) @@ -82,7 +88,11 @@ class ProjectViewSet(BaseViewSet): project_id=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), ) - projects = self.get_queryset().annotate(is_favorite=Exists(subquery)) + projects = ( + self.get_queryset() + .annotate(is_favorite=Exists(subquery)) + .order_by("-is_favorite", "name") + ) return Response(ProjectDetailSerializer(projects, many=True).data) except Exception as e: capture_exception(e) @@ -167,6 +177,12 @@ class ProjectViewSet(BaseViewSet): {"name": "The project name 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_410_GONE, + ) except Workspace.DoesNotExist as e: return Response( {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND diff --git a/apiserver/plane/api/views/search.py b/apiserver/plane/api/views/search.py new file mode 100644 index 000000000..ba75eac91 --- /dev/null +++ b/apiserver/plane/api/views/search.py @@ -0,0 +1,175 @@ +# Python imports +import re + +# Django imports +from django.db.models import Q + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from sentry_sdk import capture_exception + +# Module imports +from .base import BaseAPIView +from plane.db.models import Workspace, Project, Issue, Cycle, Module, Page, IssueView + + +class GlobalSearchEndpoint(BaseAPIView): + """Endpoint to search across multiple fields in the workspace and + also show related workspace if found + """ + + def filter_workspaces(self, query, slug, project_id): + fields = ["name"] + 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") + + 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") + + def filter_issues(self, query, slug, project_id): + 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, + 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): + fields = ["name"] + 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", + ) + + def filter_modules(self, query, slug, project_id): + fields = ["name"] + 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", + ) + + def filter_pages(self, query, slug, project_id): + fields = ["name"] + 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", + ) + + def filter_views(self, query, slug, project_id): + fields = ["name"] + 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", + ) + + def get(self, request, slug, project_id): + try: + query = request.query_params.get("search", False) + if not query: + return Response( + { + "results": { + "workspace": [], + "project": [], + "issue": [], + "cycle": [], + "module": [], + "issue_view": [], + "page": [], + } + }, + status=status.HTTP_200_OK, + ) + + MODELS_MAPPER = { + "workspace": self.filter_workspaces, + "project": self.filter_projects, + "issue": self.filter_issues, + "cycle": self.filter_cycles, + "module": self.filter_modules, + "issue_view": self.filter_views, + "page": self.filter_pages, + } + + results = {} + + for model in MODELS_MAPPER.keys(): + func = MODELS_MAPPER.get(model, None) + results[model] = func(query, slug, project_id) + return Response({"results": results}, 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, + ) diff --git a/apiserver/plane/api/views/view.py b/apiserver/plane/api/views/view.py index 4ae4ff2c1..b4e300dcb 100644 --- a/apiserver/plane/api/views/view.py +++ b/apiserver/plane/api/views/view.py @@ -1,14 +1,35 @@ +# Django imports +from django.db import IntegrityError +from django.db.models import Prefetch, OuterRef, Exists + +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from sentry_sdk import capture_exception + # Module imports -from . import BaseViewSet -from plane.api.serializers import ViewSerializer +from . import BaseViewSet, BaseAPIView +from plane.api.serializers import ( + IssueViewSerializer, + IssueLiteSerializer, + IssueViewFavoriteSerializer, +) from plane.api.permissions import ProjectEntityPermission -from plane.db.models import View +from plane.db.models import ( + IssueView, + Issue, + IssueBlocker, + IssueLink, + CycleIssue, + ModuleIssue, + IssueViewFavorite, +) +from plane.utils.issue_filters import issue_filters -class ViewViewSet(BaseViewSet): - - serializer_class = ViewSerializer - model = View +class IssueViewViewSet(BaseViewSet): + serializer_class = IssueViewSerializer + model = IssueView permission_classes = [ ProjectEntityPermission, ] @@ -17,6 +38,12 @@ class ViewViewSet(BaseViewSet): serializer.save(project_id=self.kwargs.get("project_id")) def get_queryset(self): + subquery = IssueViewFavorite.objects.filter( + user=self.request.user, + view_id=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) return self.filter_queryset( super() .get_queryset() @@ -25,5 +52,108 @@ class ViewViewSet(BaseViewSet): .filter(project__project_projectmember__member=self.request.user) .select_related("project") .select_related("workspace") + .annotate(is_favorite=Exists(subquery)) + .order_by("-is_favorite", "name") .distinct() ) + + +class ViewIssuesEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def get(self, request, slug, project_id, view_id): + try: + view = IssueView.objects.get(pk=view_id) + queries = view.query + + filters = issue_filters(request.query_params, "GET") + + issues = ( + Issue.objects.filter( + **queries, project_id=project_id, workspace__slug=slug + ) + .filter(**filters) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + ) + + serializer = IssueLiteSerializer(issues, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except IssueView.DoesNotExist: + return Response( + {"error": "Issue View does not exist"}, 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 IssueViewFavoriteViewSet(BaseViewSet): + serializer_class = IssueViewFavoriteSerializer + model = IssueViewFavorite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(user=self.request.user) + .select_related("view") + ) + + def create(self, request, slug, project_id): + try: + serializer = IssueViewFavoriteSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(user=request.user, 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 view is already added to favorites"}, + 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, + ) + except Exception as 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, view_id): + try: + view_favourite = IssueViewFavorite.objects.get( + project=project_id, + user=request.user, + workspace__slug=slug, + view_id=view_id, + ) + view_favourite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except IssueViewFavorite.DoesNotExist: + return Response( + {"error": "View is not in favorites"}, + 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 cce222605..a1c18f995 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -1,6 +1,7 @@ # Python imports import jwt -from datetime import datetime +from datetime import date, datetime +from dateutil.relativedelta import relativedelta # Django imports from django.db import IntegrityError @@ -10,8 +11,16 @@ from django.utils import timezone from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.contrib.sites.shortcuts import get_current_site -from django.db.models import CharField, Count, OuterRef, Func, F -from django.db.models.functions import Cast +from django.db.models import ( + CharField, + Count, + OuterRef, + Func, + F, + Q, +) +from django.db.models.functions import ExtractWeek, Cast, ExtractDay +from django.db.models.fields import DateField # Third party modules from rest_framework import status @@ -37,6 +46,8 @@ from plane.db.models import ( WorkspaceMemberInvite, Team, ProjectMember, + IssueActivity, + Issue, ) from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission from plane.bgtasks.workspace_invitation_task import workspace_invitation @@ -59,7 +70,9 @@ class WorkSpaceViewSet(BaseViewSet): lookup_field = "slug" def get_queryset(self): - return self.filter_queryset(super().get_queryset().select_related("owner")) + return self.filter_queryset( + super().get_queryset().select_related("owner") + ).order_by("name") def create(self, request): try: @@ -578,3 +591,164 @@ class WorkspaceMemberUserViewsEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class UserActivityGraphEndpoint(BaseAPIView): + def get(self, request, slug): + try: + issue_activities = ( + IssueActivity.objects.filter( + actor=request.user, + workspace__slug=slug, + created_at__date__gte=date.today() + relativedelta(months=-6), + ) + .annotate(created_date=Cast("created_at", DateField())) + .values("created_date") + .annotate(activity_count=Count("created_date")) + .order_by("created_date") + ) + + return Response(issue_activities, 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 UserIssueCompletedGraphEndpoint(BaseAPIView): + def get(self, request, slug): + try: + month = request.GET.get("month", 1) + + issues = ( + Issue.objects.filter( + assignees__in=[request.user], + workspace__slug=slug, + completed_at__month=month, + completed_at__isnull=False, + ) + .annotate(completed_week=ExtractWeek("completed_at")) + .annotate(week=F("completed_week") % 4) + .values("week") + .annotate(completed_count=Count("completed_week")) + .order_by("week") + ) + + return Response(issues, 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 WeekInMonth(Func): + function = "FLOOR" + template = "(((%(expressions)s - 1) / 7) + 1)::INTEGER" + + +class UserWorkspaceDashboardEndpoint(BaseAPIView): + def get(self, request, slug): + try: + issue_activities = ( + IssueActivity.objects.filter( + actor=request.user, + workspace__slug=slug, + created_at__date__gte=date.today() + relativedelta(months=-3), + ) + .annotate(created_date=Cast("created_at", DateField())) + .values("created_date") + .annotate(activity_count=Count("created_date")) + .order_by("created_date") + ) + + month = request.GET.get("month", 1) + + completed_issues = ( + Issue.objects.filter( + assignees__in=[request.user], + workspace__slug=slug, + completed_at__month=month, + completed_at__isnull=False, + ) + .annotate(day_of_month=ExtractDay("completed_at")) + .annotate(week_in_month=WeekInMonth(F("day_of_month"))) + .values("week_in_month") + .annotate(completed_count=Count("id")) + .order_by("week_in_month") + ) + + assigned_issues = Issue.objects.filter( + workspace__slug=slug, assignees__in=[request.user] + ).count() + + pending_issues_count = Issue.objects.filter( + ~Q(state__group__in=["completed", "cancelled"]), + workspace__slug=slug, + assignees__in=[request.user], + ).count() + + completed_issues_count = Issue.objects.filter( + workspace__slug=slug, + assignees__in=[request.user], + state__group="completed", + ).count() + + issues_due_week = ( + Issue.objects.filter( + workspace__slug=slug, + assignees__in=[request.user], + ) + .annotate(target_week=ExtractWeek("target_date")) + .filter(target_week=timezone.now().date().isocalendar()[1]) + .count() + ) + + state_distribution = ( + Issue.objects.filter(workspace__slug=slug, assignees__in=[request.user]) + .annotate(state_group=F("state__group")) + .values("state_group") + .annotate(state_count=Count("state_group")) + .order_by("state_group") + ) + + overdue_issues = Issue.objects.filter( + ~Q(state__group__in=["completed", "cancelled"]), + workspace__slug=slug, + assignees__in=[request.user], + target_date__lt=timezone.now(), + completed_at__isnull=True, + ).values("id", "name", "workspace__slug", "project_id", "target_date") + + upcoming_issues = Issue.objects.filter( + ~Q(state__group__in=["completed", "cancelled"]), + target_date__gte=timezone.now(), + workspace__slug=slug, + assignees__in=[request.user], + completed_at__isnull=True, + ).values("id", "name", "workspace__slug", "project_id", "target_date") + + return Response( + { + "issue_activities": issue_activities, + "completed_issues": completed_issues, + "assigned_issues_count": assigned_issues, + "pending_issues_count": pending_issues_count, + "completed_issues_count": completed_issues_count, + "issues_due_week_count": issues_due_week, + "state_distribution": state_distribution, + "overdue_issues": overdue_issues, + "upcoming_issues": upcoming_issues, + }, + 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/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py new file mode 100644 index 000000000..f5dadf322 --- /dev/null +++ b/apiserver/plane/bgtasks/importer_task.py @@ -0,0 +1,163 @@ +# Python imports +import json +import requests +import uuid +import jwt +from datetime import datetime + +# Django imports +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder +from django.contrib.auth.hashers import make_password + +# Third Party imports +from django_rq import job +from sentry_sdk import capture_exception + +# Module imports +from plane.api.serializers import ImporterSerializer +from plane.db.models import ( + Importer, + WorkspaceMember, + GithubRepositorySync, + GithubRepository, + ProjectMember, + WorkspaceIntegration, + Label, + User, +) +from .workspace_invitation_task import workspace_invitation + + +@job("default") +def service_importer(service, importer_id): + try: + importer = Importer.objects.get(pk=importer_id) + importer.status = "processing" + importer.save() + + users = importer.data.get("users", []) + + # For all invited users create the uers + new_users = User.objects.bulk_create( + [ + User( + email=user.get("email").strip().lower(), + username=uuid.uuid4().hex, + password=make_password(uuid.uuid4().hex), + is_password_autoset=True, + ) + for user in users + if user.get("import", False) == "invite" + ], + batch_size=10, + ignore_conflicts=True, + ) + + workspace_users = User.objects.filter( + email__in=[ + user.get("email").strip().lower() + for user in users + if user.get("import", False) == "invite" + or user.get("import", False) == "map" + ] + ) + + + # Add new users to Workspace and project automatically + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember(member=user, workspace_id=importer.workspace_id) + for user in workspace_users + ], + batch_size=100, + ignore_conflicts=True, + ) + + ProjectMember.objects.bulk_create( + [ + ProjectMember( + project_id=importer.project_id, + workspace_id=importer.workspace_id, + member=user, + ) + for user in workspace_users + ], + batch_size=100, + ignore_conflicts=True, + ) + + # Check if sync config is on for github importers + if service == "github" and importer.config.get("sync", False): + name = importer.metadata.get("name", False) + url = importer.metadata.get("url", False) + config = importer.metadata.get("config", {}) + owner = importer.metadata.get("owner", False) + repository_id = importer.metadata.get("repository_id", False) + + workspace_integration = WorkspaceIntegration.objects.get( + workspace_id=importer.workspace_id, integration__provider="github" + ) + + # Delete the old repository object + GithubRepositorySync.objects.filter(project_id=importer.project_id).delete() + GithubRepository.objects.filter(project_id=importer.project_id).delete() + + # Create a Label for github + label = Label.objects.filter( + name="GitHub", project_id=importer.project_id + ).first() + + if label is None: + label = Label.objects.create( + name="GitHub", + project_id=importer.project_id, + description="Label to sync Plane issues with GitHub issues", + color="#003773", + ) + # Create repository + repo = GithubRepository.objects.create( + name=name, + url=url, + config=config, + repository_id=repository_id, + owner=owner, + project_id=importer.project_id, + ) + + # Create repo sync + repo_sync = GithubRepositorySync.objects.create( + repository=repo, + workspace_integration=workspace_integration, + actor=workspace_integration.actor, + credentials=importer.data.get("credentials", {}), + project_id=importer.project_id, + label=label, + ) + + # Add bot as a member in the project + _ = ProjectMember.objects.get_or_create( + member=workspace_integration.actor, + role=20, + project_id=importer.project_id, + ) + + if settings.PROXY_BASE_URL: + headers = {"Content-Type": "application/json"} + import_data_json = json.dumps( + ImporterSerializer(importer).data, + cls=DjangoJSONEncoder, + ) + res = requests.post( + f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(importer.workspace_id)}/projects/{str(importer.project_id)}/importers/{str(service)}/", + json=import_data_json, + headers=headers, + ) + + return + except Exception as e: + importer = Importer.objects.get(pk=importer_id) + importer.status = "failed" + importer.save() + capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index a9bf30712..efc7f196e 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -676,8 +676,8 @@ def create_comment_activity( verb="created", actor=actor, field="comment", - new_value=requested_data.get("comment_html"), - new_identifier=requested_data.get("id"), + new_value=requested_data.get("comment_html", ""), + new_identifier=requested_data.get("id", None), issue_comment_id=requested_data.get("id", None), ) ) @@ -696,11 +696,11 @@ def update_comment_activity( verb="updated", actor=actor, field="comment", - old_value=current_instance.get("comment_html"), + old_value=current_instance.get("comment_html", ""), old_identifier=current_instance.get("id"), - new_value=requested_data.get("comment_html"), - new_identifier=current_instance.get("id"), - issue_comment_id=current_instance.get("id"), + new_value=requested_data.get("comment_html", ""), + new_identifier=current_instance.get("id", None), + issue_comment_id=current_instance.get("id", None), ) ) @@ -742,7 +742,11 @@ def issue_activity(event): try: issue_activities = [] type = event.get("type") - requested_data = json.loads(event.get("requested_data")) + 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 diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index 706635588..0ed807171 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -2,10 +2,13 @@ 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 django_rq import job from sentry_sdk import capture_exception +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError # Module imports from plane.db.models import Workspace, User, WorkspaceMemberInvite @@ -13,9 +16,7 @@ from plane.db.models import Workspace, User, WorkspaceMemberInvite @job("default") def workspace_invitation(email, workspace_id, token, current_site, invitor): - try: - workspace = Workspace.objects.get(pk=workspace_id) workspace_member_invite = WorkspaceMemberInvite.objects.get( token=token, email=email @@ -49,6 +50,18 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): msg = EmailMultiAlternatives(subject, text_content, from_email_string, [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=f"{workspace_member_invite.email} has been invited to {workspace.name} as a {workspace_member_invite.role}", + ) + except SlackApiError as e: + print(f"Got an error: {e.response['error']}") + return except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e: return diff --git a/apiserver/plane/db/migrations/0023_auto_20230316_0040.py b/apiserver/plane/db/migrations/0023_auto_20230316_0040.py new file mode 100644 index 000000000..c6985866c --- /dev/null +++ b/apiserver/plane/db/migrations/0023_auto_20230316_0040.py @@ -0,0 +1,92 @@ +# Generated by Django 3.2.16 on 2023-03-15 19:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0022_auto_20230307_0304'), + ] + + operations = [ + migrations.CreateModel( + name='Importer', + 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)), + ('service', models.CharField(choices=[('github', 'GitHub')], max_length=50)), + ('status', models.CharField(choices=[('queued', 'Queued'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='queued', max_length=50)), + ('metadata', models.JSONField(default=dict)), + ('config', models.JSONField(default=dict)), + ('data', models.JSONField(default=dict)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='importer_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='imports', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_importer', to='db.project')), + ('token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='importer', to='db.apitoken')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='importer_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_importer', to='db.workspace')), + ], + options={ + 'verbose_name': 'Importer', + 'verbose_name_plural': 'Importers', + 'db_table': 'importers', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueView', + 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, verbose_name='View Name')), + ('description', models.TextField(blank=True, verbose_name='View Description')), + ('query', models.JSONField(verbose_name='View Query')), + ('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)), + ('query_data', models.JSONField(default=dict)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueview_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueview', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueview_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_issueview', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue View', + 'verbose_name_plural': 'Issue Views', + 'db_table': 'issue_views', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueViewFavorite', + 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)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueviewfavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueviewfavorite', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueviewfavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_view_favorites', to=settings.AUTH_USER_MODEL)), + ('view', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='view_favorites', to='db.issueview')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueviewfavorite', to='db.workspace')), + ], + options={ + 'verbose_name': 'View Favorite', + 'verbose_name_plural': 'View Favorites', + 'db_table': 'view_favorites', + 'ordering': ('-created_at',), + 'unique_together': {('view', 'user')}, + }, + ), + migrations.AlterUniqueTogether( + name='label', + unique_together={('name', 'project')}, + ), + migrations.DeleteModel( + name='View', + ), + ] diff --git a/apiserver/plane/db/migrations/0024_auto_20230322_0138.py b/apiserver/plane/db/migrations/0024_auto_20230322_0138.py new file mode 100644 index 000000000..65880891a --- /dev/null +++ b/apiserver/plane/db/migrations/0024_auto_20230322_0138.py @@ -0,0 +1,113 @@ +# Generated by Django 3.2.16 on 2023-03-21 20:08 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0023_auto_20230316_0040'), + ] + + operations = [ + migrations.CreateModel( + name='Page', + 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.JSONField(blank=True, default=dict)), + ('description_html', models.TextField(blank=True, default='

')), + ('description_stripped', models.TextField(blank=True, null=True)), + ('access', models.PositiveSmallIntegerField(choices=[(0, 'Public'), (1, 'Private')], default=0)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='page_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pages', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Page', + 'verbose_name_plural': 'Pages', + 'db_table': 'pages', + 'ordering': ('-created_at',), + }, + ), + migrations.AddField( + model_name='project', + name='issue_views_view', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='importer', + name='service', + field=models.CharField(choices=[('github', 'GitHub'), ('jira', 'Jira')], max_length=50), + ), + migrations.AlterField( + model_name='project', + name='cover_image', + field=models.URLField(blank=True, max_length=800, null=True), + ), + migrations.CreateModel( + name='PageBlock', + 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.JSONField(blank=True, default=dict)), + ('description_html', models.TextField(blank=True, default='

')), + ('description_stripped', models.TextField(blank=True, null=True)), + ('completed_at', models.DateTimeField(null=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pageblock_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blocks', to='db.issue')), + ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocks', to='db.page')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_pageblock', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pageblock_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_pageblock', to='db.workspace')), + ], + options={ + 'verbose_name': 'Page Block', + 'verbose_name_plural': 'Page Blocks', + 'db_table': 'page_blocks', + 'ordering': ('-created_at',), + }, + ), + migrations.AddField( + model_name='page', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_page', to='db.project'), + ), + migrations.AddField( + model_name='page', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='page_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AddField( + model_name='page', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_page', to='db.workspace'), + ), + migrations.CreateModel( + name='PageFavorite', + 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)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagefavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_favorites', to='db.page')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_pagefavorite', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagefavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_favorites', to=settings.AUTH_USER_MODEL)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_pagefavorite', to='db.workspace')), + ], + options={ + 'verbose_name': 'Page Favorite', + 'verbose_name_plural': 'Page Favorites', + 'db_table': 'page_favorites', + 'ordering': ('-created_at',), + 'unique_together': {('page', 'user')}, + }, + ), + ] diff --git a/apiserver/plane/db/migrations/0025_auto_20230331_0203.py b/apiserver/plane/db/migrations/0025_auto_20230331_0203.py new file mode 100644 index 000000000..1097a4612 --- /dev/null +++ b/apiserver/plane/db/migrations/0025_auto_20230331_0203.py @@ -0,0 +1,61 @@ +# Generated by Django 3.2.18 on 2023-03-30 20:33 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0024_auto_20230322_0138'), + ] + + operations = [ + migrations.AddField( + model_name='page', + name='color', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='pageblock', + name='sort_order', + field=models.FloatField(default=65535), + ), + migrations.AddField( + model_name='pageblock', + name='sync', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='project', + name='page_view', + field=models.BooleanField(default=True), + ), + migrations.CreateModel( + name='PageLabel', + 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)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagelabel_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('label', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_labels', to='db.label')), + ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_labels', to='db.page')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_pagelabel', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagelabel_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_pagelabel', to='db.workspace')), + ], + options={ + 'verbose_name': 'Page Label', + 'verbose_name_plural': 'Page Labels', + 'db_table': 'page_labels', + 'ordering': ('-created_at',), + }, + ), + migrations.AddField( + model_name='page', + name='labels', + field=models.ManyToManyField(blank=True, related_name='pages', through='db.PageLabel', to='db.Label'), + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 09b44b422..8a3021741 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -31,6 +31,7 @@ from .issue import ( Label, IssueBlocker, IssueLink, + IssueSequence, ) from .asset import FileAsset @@ -43,7 +44,7 @@ from .cycle import Cycle, CycleIssue, CycleFavorite from .shortcut import Shortcut -from .view import View +from .view import IssueView, IssueViewFavorite from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite @@ -57,3 +58,7 @@ from .integration import ( GithubIssueSync, GithubCommentSync, ) + +from .importer import Importer + +from .page import Page, PageBlock, PageFavorite, PageLabel \ No newline at end of file diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py index ff33dc9e0..acbb9428f 100644 --- a/apiserver/plane/db/models/asset.py +++ b/apiserver/plane/db/models/asset.py @@ -1,3 +1,6 @@ +# Python imports +from uuid import uuid4 + # Django import from django.db import models from django.core.exceptions import ValidationError @@ -7,7 +10,9 @@ from . import BaseModel def get_upload_path(instance, filename): - return f"{instance.workspace.id}/{filename}" + if instance.workspace_id is not None: + return f"{instance.workspace.id}/{uuid4().hex}-{filename}" + return f"user-{uuid4().hex}-{filename}" def file_size(value): @@ -15,6 +20,7 @@ def file_size(value): if value.size > limit: raise ValidationError("File too large. Size should not exceed 5 MB.") + class FileAsset(BaseModel): """ A file asset. diff --git a/apiserver/plane/db/models/importer.py b/apiserver/plane/db/models/importer.py new file mode 100644 index 000000000..a61aae48c --- /dev/null +++ b/apiserver/plane/db/models/importer.py @@ -0,0 +1,45 @@ +# Django imports +from django.db import models +from django.conf import settings + +# Module imports +from . import ProjectBaseModel + + +class Importer(ProjectBaseModel): + service = models.CharField( + max_length=50, + choices=( + ("github", "GitHub"), + ("jira", "Jira"), + ), + ) + status = models.CharField( + max_length=50, + choices=( + ("queued", "Queued"), + ("processing", "Processing"), + ("completed", "Completed"), + ("failed", "Failed"), + ), + default="queued", + ) + initiated_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="imports" + ) + metadata = models.JSONField(default=dict) + config = models.JSONField(default=dict) + data = models.JSONField(default=dict) + token = models.ForeignKey( + "db.APIToken", on_delete=models.CASCADE, related_name="importer" + ) + + class Meta: + verbose_name = "Importer" + verbose_name_plural = "Importers" + db_table = "importers" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the service""" + return f"{self.service} <{self.project.name}>" diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index fc9971000..655a03e75 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -85,7 +85,7 @@ class Issue(ProjectBaseModel): pass else: try: - from plane.db.models import State + from plane.db.models import State, PageBlock # Get the completed states of the project completed_states = State.objects.filter( @@ -94,7 +94,15 @@ class Issue(ProjectBaseModel): # Check if the current issue state and completed state id are same if self.state.id in completed_states: self.completed_at = timezone.now() + # check if there are any page blocks + PageBlock.objects.filter(issue_id=self.id).filter().update( + completed_at=timezone.now() + ) + else: + PageBlock.objects.filter(issue_id=self.id).filter().update( + completed_at=None + ) self.completed_at = None except ImportError: @@ -307,6 +315,7 @@ class Label(ProjectBaseModel): color = models.CharField(max_length=255, blank=True) class Meta: + unique_together = ["name", "project"] verbose_name = "Label" verbose_name_plural = "Labels" db_table = "labels" diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py new file mode 100644 index 000000000..557fcb323 --- /dev/null +++ b/apiserver/plane/db/models/page.py @@ -0,0 +1,126 @@ +# Django imports +from django.db import models +from django.conf import settings + +# Module imports +from . import ProjectBaseModel +from plane.utils.html_processor import strip_tags + + +class Page(ProjectBaseModel): + name = models.CharField(max_length=255) + description = models.JSONField(default=dict, blank=True) + description_html = models.TextField(blank=True, default="

") + description_stripped = models.TextField(blank=True, null=True) + owned_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pages" + ) + access = models.PositiveSmallIntegerField( + choices=((0, "Public"), (1, "Private")), default=0 + ) + color = models.CharField(max_length=255, blank=True) + labels = models.ManyToManyField( + "db.Label", blank=True, related_name="pages", through="db.PageLabel" + ) + + class Meta: + verbose_name = "Page" + verbose_name_plural = "Pages" + db_table = "pages" + ordering = ("-created_at",) + + def __str__(self): + """Return owner email and page name""" + return f"{self.owned_by.email} <{self.name}>" + + +class PageBlock(ProjectBaseModel): + page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="blocks") + name = models.CharField(max_length=255) + description = models.JSONField(default=dict, blank=True) + description_html = models.TextField(blank=True, default="

") + description_stripped = models.TextField(blank=True, null=True) + issue = models.ForeignKey( + "db.Issue", on_delete=models.SET_NULL, related_name="blocks", null=True + ) + completed_at = models.DateTimeField(null=True) + sort_order = models.FloatField(default=65535) + sync = models.BooleanField(default=True) + + def save(self, *args, **kwargs): + if self._state.adding: + largest_sort_order = PageBlock.objects.filter( + project=self.project, page=self.page + ).aggregate(largest=models.Max("sort_order"))["largest"] + if largest_sort_order is not None: + self.sort_order = largest_sort_order + 10000 + + # Strip the html tags using html parser + self.description_stripped = ( + None + if (self.description_html == "" or self.description_html is None) + else strip_tags(self.description_html) + ) + + if self.completed_at and self.issue: + try: + from plane.db.models import State, Issue + + completed_state = State.objects.filter( + group="completed", project=self.project + ).first() + if completed_state is not None: + Issue.objects.update(pk=self.issue_id, state=completed_state) + except ImportError: + pass + super(PageBlock, self).save(*args, **kwargs) + + class Meta: + verbose_name = "Page Block" + verbose_name_plural = "Page Blocks" + db_table = "page_blocks" + ordering = ("-created_at",) + + def __str__(self): + """Return page and page block""" + return f"{self.page.name} <{self.name}>" + + +class PageFavorite(ProjectBaseModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="page_favorites", + ) + page = models.ForeignKey( + "db.Page", on_delete=models.CASCADE, related_name="page_favorites" + ) + + class Meta: + unique_together = ["page", "user"] + verbose_name = "Page Favorite" + verbose_name_plural = "Page Favorites" + db_table = "page_favorites" + ordering = ("-created_at",) + + def __str__(self): + """Return user and the page""" + return f"{self.user.email} <{self.page.name}>" + + +class PageLabel(ProjectBaseModel): + label = models.ForeignKey( + "db.Label", on_delete=models.CASCADE, related_name="page_labels" + ) + page = models.ForeignKey( + "db.Page", on_delete=models.CASCADE, related_name="page_labels" + ) + + class Meta: + verbose_name = "Page Label" + verbose_name_plural = "Page Labels" + db_table = "page_labels" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.page.name} {self.label.name}" diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 6153d0926..4b1af4bed 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -63,7 +63,9 @@ class Project(BaseModel): icon = models.CharField(max_length=255, null=True, blank=True) module_view = models.BooleanField(default=True) cycle_view = models.BooleanField(default=True) - cover_image = models.URLField(blank=True, null=True) + issue_views_view = models.BooleanField(default=True) + page_view = models.BooleanField(default=True) + cover_image = models.URLField(blank=True, null=True, max_length=800) def __str__(self): """Return name of the project""" diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 896681808..8a30981f3 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -11,9 +11,12 @@ from django.utils import timezone 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 sentry_sdk import capture_exception +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError class User(AbstractBaseUser, PermissionsMixin): @@ -123,6 +126,16 @@ def send_welcome_email(sender, instance, created, **kwargs): 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=f"New user {instance.email} has signed up and begun the onboarding journey.", + ) + except SlackApiError as e: + print(f"Got an error: {e.response['error']}") return except Exception as e: capture_exception(e) diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py index c3ea9a866..6a968af53 100644 --- a/apiserver/plane/db/models/view.py +++ b/apiserver/plane/db/models/view.py @@ -1,22 +1,48 @@ # Django imports from django.db import models - +from django.conf import settings # Module import from . import ProjectBaseModel -class View(ProjectBaseModel): +class IssueView(ProjectBaseModel): name = models.CharField(max_length=255, verbose_name="View Name") description = models.TextField(verbose_name="View Description", blank=True) query = models.JSONField(verbose_name="View Query") + access = models.PositiveSmallIntegerField( + default=1, choices=((0, "Private"), (1, "Public")) + ) + query_data = models.JSONField(default=dict) class Meta: - verbose_name = "View" - verbose_name_plural = "Views" - db_table = "views" + verbose_name = "Issue View" + verbose_name_plural = "Issue Views" + db_table = "issue_views" ordering = ("-created_at",) def __str__(self): """Return name of the View""" return f"{self.name} <{self.project.name}>" + + +class IssueViewFavorite(ProjectBaseModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="user_view_favorites", + ) + view = models.ForeignKey( + "db.IssueView", on_delete=models.CASCADE, related_name="view_favorites" + ) + + class Meta: + unique_together = ["view", "user"] + verbose_name = "View Favorite" + verbose_name_plural = "View Favorites" + db_table = "view_favorites" + ordering = ("-created_at",) + + def __str__(self): + """Return user and the view""" + return f"{self.user.email} <{self.view.name}>" diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 9d270662e..73c3c4be5 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -48,6 +48,7 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "crum.CurrentRequestUserMiddleware", + "django.middleware.gzip.GZipMiddleware", ] REST_FRAMEWORK = { diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index ccb388012..bf161568b 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -78,3 +78,13 @@ if DOCKERIZED: WEB_URL = os.environ.get("WEB_URL", "localhost:3000") PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) + +ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) +ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) + +OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) +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) diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 1b6ac2cf7..5569e1c09 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -22,13 +22,7 @@ DATABASES = { } } -# CORS WHITELIST ON PROD -CORS_ORIGIN_WHITELIST = [ - # "https://example.com", - # "https://sub.example.com", - # "http://localhost:8080", - # "http://127.0.0.1:9000" -] + # Parse database configuration from $DATABASE_URL DATABASES["default"] = dj_database_url.config() SITE_ID = 1 @@ -43,12 +37,33 @@ DOCKERIZED = os.environ.get( # Honor the 'X-Forwarded-Proto' header for request.is_secure() SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") -# Allow all host headers -ALLOWED_HOSTS = ["*"] # TODO: Make it FALSE and LIST DOMAINS IN FULL PROD. CORS_ALLOW_ALL_ORIGINS = True + +CORS_ALLOW_METHODS = [ + "DELETE", + "GET", + "OPTIONS", + "PATCH", + "POST", + "PUT", +] + +CORS_ALLOW_HEADERS = [ + "accept", + "accept-encoding", + "authorization", + "content-type", + "dnt", + "origin", + "user-agent", + "x-csrftoken", + "x-requested-with", +] + +CORS_ALLOW_CREDENTIALS = True # Simplified static file serving. STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" @@ -211,3 +226,13 @@ RQ_QUEUES = { WEB_URL = os.environ.get("WEB_URL") PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) + +ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) +ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) + +OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) +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) diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py index 0e58ab224..9015ce03f 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -187,3 +187,13 @@ RQ_QUEUES = { WEB_URL = os.environ.get("WEB_URL") PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) + +ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) +ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) + +OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) +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) diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py index 798b652fa..535bf6eba 100644 --- a/apiserver/plane/utils/grouper.py +++ b/apiserver/plane/utils/grouper.py @@ -27,6 +27,15 @@ def group_results(results_data, group_by): """ response_dict = dict() + if group_by == "priority": + response_dict = { + "urgent": [], + "high": [], + "medium": [], + "low": [], + "None": [], + } + for value in results_data: group_attribute = resolve_keys(group_by, value) if isinstance(group_attribute, list): diff --git a/apiserver/plane/utils/importers/__init__.py b/apiserver/plane/utils/importers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/utils/importers/jira.py b/apiserver/plane/utils/importers/jira.py new file mode 100644 index 000000000..a5888e2ec --- /dev/null +++ b/apiserver/plane/utils/importers/jira.py @@ -0,0 +1,53 @@ +import requests +from requests.auth import HTTPBasicAuth +from sentry_sdk import capture_exception + + +def jira_project_issue_summary(email, api_token, project_name, 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_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_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_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_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}" + ) + users_response = requests.request( + "GET", users_url, headers=headers, auth=auth + ).json() + + return { + "issues": issue_response, + "modules": module_response, + "labels": labels_response, + "states": len(status_response), + "users": ( + [ + user + for user in users_response + if user.get("accountType") == "atlassian" + ] + ), + } + except Exception as e: + capture_exception(e) + return {"error": "Something went wrong could not fetch information from jira"} diff --git a/apiserver/plane/utils/integrations/github.py b/apiserver/plane/utils/integrations/github.py index e06ac31f7..d9185cb10 100644 --- a/apiserver/plane/utils/integrations/github.py +++ b/apiserver/plane/utils/integrations/github.py @@ -1,6 +1,7 @@ import os import jwt import requests +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 @@ -30,7 +31,7 @@ def get_github_metadata(installation_id): url = f"https://api.github.com/app/installations/{installation_id}" headers = { - "Authorization": "Bearer " + token, + "Authorization": "Bearer " + str(token), "Accept": "application/vnd.github+json", } response = requests.get(url, headers=headers).json() @@ -41,7 +42,7 @@ def get_github_repos(access_tokens_url, repositories_url): token = get_jwt_token() headers = { - "Authorization": "Bearer " + token, + "Authorization": "Bearer " + str(token), "Accept": "application/vnd.github+json", } @@ -50,9 +51,9 @@ def get_github_repos(access_tokens_url, repositories_url): headers=headers, ).json() - oauth_token = oauth_response.get("token") + oauth_token = oauth_response.get("token", "") headers = { - "Authorization": "Bearer " + oauth_token, + "Authorization": "Bearer " + str(oauth_token), "Accept": "application/vnd.github+json", } response = requests.get( @@ -67,8 +68,63 @@ def delete_github_installation(installation_id): url = f"https://api.github.com/app/installations/{installation_id}" headers = { - "Authorization": "Bearer " + token, + "Authorization": "Bearer " + str(token), "Accept": "application/vnd.github+json", } response = requests.delete(url, headers=headers) return response + + +def get_github_repo_details(access_tokens_url, owner, repo): + token = get_jwt_token() + + headers = { + "Authorization": "Bearer " + str(token), + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + oauth_response = requests.post( + access_tokens_url, + headers=headers, + ).json() + + oauth_token = oauth_response.get("token") + headers = { + "Authorization": "Bearer " + oauth_token, + "Accept": "application/vnd.github+json", + } + open_issues = requests.get( + f"https://api.github.com/repos/{owner}/{repo}", + headers=headers, + ).json()["open_issues_count"] + + total_labels = 0 + + labels_response = requests.get( + f"https://api.github.com/repos/{owner}/{repo}/labels?per_page=100&page=1", + headers=headers, + ) + + # Check if there are more pages + if len(labels_response.links.keys()): + # get the query parameter of last + last_url = labels_response.links.get("last").get("url") + parsed_url = urlparse(last_url) + last_page_value = parse_qs(parsed_url.query)["page"][0] + total_labels = total_labels + 100 * (last_page_value - 1) + + # Get labels in last page + last_page_labels = requests.get(last_url, headers=headers).json() + total_labels = total_labels + len(last_page_labels) + else: + total_labels = len(labels_response.json()) + + # Currently only supporting upto 100 collaborators + # TODO: Update this function to fetch all collaborators + collaborators = requests.get( + f"https://api.github.com/repos/{owner}/{repo}/collaborators?per_page=100&page=1", + headers=headers, + ).json() + + return open_issues, total_labels, collaborators diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py new file mode 100644 index 000000000..81ee30bac --- /dev/null +++ b/apiserver/plane/utils/issue_filters.py @@ -0,0 +1,214 @@ +from django.utils.timezone import make_aware +from django.utils.dateparse import parse_datetime + + +def filter_state(params, filter, method): + if method == "GET": + states = params.get("state").split(",") + if len(states) and "" not in states: + filter["state__in"] = states + else: + if params.get("state", None) and len(params.get("state")): + filter["state__in"] = params.get("state") + return filter + + +def filter_priority(params, filter, method): + if method == "GET": + priorties = params.get("priority").split(",") + if len(priorties) and "" not in priorties: + filter["priority__in"] = priorties + else: + if params.get("priority", None) and len(params.get("priority")): + filter["priority__in"] = params.get("priority") + return filter + + +def filter_parent(params, filter, method): + if method == "GET": + parents = params.get("parent").split(",") + if len(parents) and "" not in parents: + filter["parent__in"] = parents + else: + if params.get("parent", None) and len(params.get("parent")): + filter["parent__in"] = params.get("parent") + return filter + + +def filter_labels(params, filter, method): + if method == "GET": + labels = params.get("labels").split(",") + if len(labels) and "" not in labels: + filter["labels__in"] = labels + else: + if params.get("labels", None) and len(params.get("labels")): + filter["labels__in"] = params.get("labels") + return filter + + +def filter_assignees(params, filter, method): + if method == "GET": + assignees = params.get("assignees").split(",") + if len(assignees) and "" not in assignees: + filter["assignees__in"] = assignees + else: + if params.get("assignees", None) and len(params.get("assignees")): + filter["assignees__in"] = params.get("assignees") + return filter + + +def filter_created_by(params, filter, method): + if method == "GET": + created_bys = params.get("created_by").split(",") + if len(created_bys) and "" not in created_bys: + filter["created_by__in"] = created_bys + else: + if params.get("created_by", None) and len(params.get("created_by")): + filter["created_by__in"] = params.get("created_by") + return filter + + +def filter_name(params, filter, method): + if params.get("name", "") != "": + filter["name__icontains"] = params.get("name") + return filter + + +def filter_created_at(params, filter, method): + if method == "GET": + created_ats = params.get("created_at").split(",") + if len(created_ats) and "" not in created_ats: + for query in created_ats: + created_at_query = query.split(";") + if len(created_at_query) == 2 and "after" in created_at_query: + filter["created_at__date__gte"] = created_at_query[0] + else: + filter["created_at__date__lte"] = created_at_query[0] + else: + if params.get("created_at", None) and len(params.get("created_at")): + for query in params.get("created_at"): + if query.get("timeline", "after") == "after": + filter["created_at__date__gte"] = query.get("datetime") + else: + filter["created_at__date__lte"] = query.get("datetime") + return filter + + +def filter_updated_at(params, filter, method): + if method == "GET": + updated_ats = params.get("updated_at").split(",") + if len(updated_ats) and "" not in updated_ats: + for query in updated_ats: + updated_at_query = query.split(";") + if len(updated_at_query) == 2 and "after" in updated_at_query: + filter["updated_at__date__gte"] = updated_at_query[0] + else: + filter["updated_at__date__lte"] = updated_at_query[0] + else: + if params.get("updated_at", None) and len(params.get("updated_at")): + for query in params.get("updated_at"): + if query.get("timeline", "after") == "after": + filter["updated_at__date__gte"] = query.get("datetime") + else: + filter["updated_at__date__lte"] = query.get("datetime") + return filter + + +def filter_start_date(params, filter, method): + if method == "GET": + start_dates = params.get("start_date").split(",") + if len(start_dates) and "" not in start_dates: + for query in start_dates: + start_date_query = query.split(";") + if len(start_date_query) == 2 and "after" in start_date_query: + filter["start_date__gte"] = start_date_query[0] + else: + filter["start_date__lte"] = start_date_query[0] + else: + if params.get("start_date", None) and len(params.get("start_date")): + for query in params.get("start_date"): + if query.get("timeline", "after") == "after": + filter["start_date__gte"] = query.get("datetime") + else: + filter["start_date__lte"] = query.get("datetime") + return filter + + +def filter_target_date(params, filter, method): + if method == "GET": + target_dates = params.get("target_date").split(",") + if len(target_dates) and "" not in target_dates: + for query in target_dates: + target_date_query = query.split(";") + if len(target_date_query) == 2 and "after" in target_date_query: + filter["target_date__gte"] = target_date_query[0] + else: + filter["target_date__lte"] = target_date_query[0] + else: + if params.get("target_date", None) and len(params.get("target_date")): + for query in params.get("target_date"): + if query.get("timeline", "after") == "after": + filter["target_date__gte"] = query.get("datetime") + else: + filter["target_date__lte"] = query.get("datetime") + + return filter + + +def filter_completed_at(params, filter, method): + if method == "GET": + completed_ats = params.get("completed_at").split(",") + if len(completed_ats) and "" not in completed_ats: + for query in completed_ats: + completed_at_query = query.split(";") + if len(completed_at_query) == 2 and "after" in completed_at_query: + filter["completed_at__date__gte"] = completed_at_query[0] + else: + filter["completed_at__lte"] = completed_at_query[0] + else: + if params.get("completed_at", None) and len(params.get("completed_at")): + for query in params.get("completed_at"): + if query.get("timeline", "after") == "after": + filter["completed_at__date__gte"] = query.get("datetime") + else: + filter["completed_at__lte"] = query.get("datetime") + return filter + + +def filter_issue_state_type(params, filter, method): + type = params.get("type", "all") + group = ["backlog", "unstarted", "started", "completed", "cancelled"] + if type == "backlog": + group = ["backlog"] + if type == "active": + group = ["unstarted", "started"] + + filter["state__group__in"] = group + return filter + + +def issue_filters(query_params, method): + filter = dict() + + ISSUE_FILTER = { + "state": filter_state, + "priority": filter_priority, + "parent": filter_parent, + "labels": filter_labels, + "assignees": filter_assignees, + "created_by": filter_created_by, + "name": filter_name, + "created_at": filter_created_at, + "updated_at": filter_updated_at, + "start_date": filter_start_date, + "target_date": filter_target_date, + "completed_at": filter_completed_at, + "type": filter_issue_state_type, + } + + for key, value in ISSUE_FILTER.items(): + if key in query_params: + func = value + func(query_params, filter, method) + + return filter diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index ffe11a234..a1e6c0b71 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -7,7 +7,7 @@ psycopg2==2.9.5 django-oauth-toolkit==2.2.0 mistune==2.0.4 djangorestframework==3.14.0 -redis==4.4.2 +redis==4.5.4 django-nested-admin==4.0.2 django-cors-headers==3.13.0 whitenoise==6.3.0 @@ -26,4 +26,6 @@ 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 \ No newline at end of file +channels==4.0.0 +openai==0.27.2 +slack-sdk==3.20.2 \ No newline at end of file diff --git a/apps/app/.env.example b/apps/app/.env.example index 50747dcc6..371a64c80 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -1,7 +1,8 @@ -NEXT_PUBLIC_API_BASE_URL = "http://localhost" -NEXT_PUBLIC_GOOGLE_CLIENTID="<-- google client id -->" -NEXT_PUBLIC_GITHUB_APP_NAME="<-- github app name -->" -NEXT_PUBLIC_GITHUB_ID="<-- github client id -->" -NEXT_PUBLIC_SENTRY_DSN="<-- sentry dns -->" +# Replace with your instance Public IP +# NEXT_PUBLIC_API_BASE_URL = "http://localhost" +NEXT_PUBLIC_GOOGLE_CLIENTID="" +NEXT_PUBLIC_GITHUB_APP_NAME="" +NEXT_PUBLIC_GITHUB_ID="" +NEXT_PUBLIC_SENTRY_DSN="" NEXT_PUBLIC_ENABLE_OAUTH=0 NEXT_PUBLIC_ENABLE_SENTRY=0 \ No newline at end of file diff --git a/apps/app/components/account/email-code-form.tsx b/apps/app/components/account/email-code-form.tsx index db201041c..ad46b6758 100644 --- a/apps/app/components/account/email-code-form.tsx +++ b/apps/app/components/account/email-code-form.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; // ui import { CheckCircleIcon } from "@heroicons/react/20/solid"; -import { Button, Input } from "components/ui"; +import { Input, PrimaryButton, SecondaryButton } from "components/ui"; // services import authenticationService from "services/authentication.service"; import useToast from "hooks/use-toast"; @@ -90,7 +90,7 @@ export const EmailCodeForm = ({ onSuccess }: any) => { return ( <> -
+ {(codeSent || codeResent) && (
@@ -121,7 +121,7 @@ export const EmailCodeForm = ({ onSuccess }: any) => { ) || "Email ID is not valid", }} error={errors.email} - placeholder="Enter your Email ID" + placeholder="Enter you email Id" />
@@ -140,8 +140,8 @@ export const EmailCodeForm = ({ onSuccess }: any) => { /> + ) : ( - + )}
diff --git a/apps/app/components/account/email-password-form.tsx b/apps/app/components/account/email-password-form.tsx index 613077ac3..ca654c81c 100644 --- a/apps/app/components/account/email-password-form.tsx +++ b/apps/app/components/account/email-password-form.tsx @@ -1,13 +1,15 @@ import React from "react"; -// next + import Link from "next/link"; + // react hook form import { useForm } from "react-hook-form"; -// ui -import { Button, Input } from "components/ui"; +// services import authenticationService from "services/authentication.service"; +// hooks import useToast from "hooks/use-toast"; - +// ui +import { Input, SecondaryButton } from "components/ui"; // types type EmailPasswordFormValues = { email: string; @@ -58,7 +60,7 @@ export const EmailPasswordForm = ({ onSuccess }: any) => { }; return ( <> -
+
{
- +
diff --git a/apps/app/components/account/email-signin-form.tsx b/apps/app/components/account/email-signin-form.tsx index 5e47f36d3..e2f81d50c 100644 --- a/apps/app/components/account/email-signin-form.tsx +++ b/apps/app/components/account/email-signin-form.tsx @@ -19,28 +19,6 @@ export const EmailSignInForm: FC = (props) => { ) : ( )} -
-
-
-
-
-
- or -
-
- {/*
- -
*/} -
); }; diff --git a/apps/app/components/account/github-login-button.tsx b/apps/app/components/account/github-login-button.tsx index 80faecec5..92e9cdccd 100644 --- a/apps/app/components/account/github-login-button.tsx +++ b/apps/app/components/account/github-login-button.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/router"; // images -import githubImage from "/public/logos/github.png"; +import githubImage from "/public/logos/github-black.png"; const { NEXT_PUBLIC_GITHUB_ID } = process.env; @@ -33,19 +33,15 @@ export const GithubLoginButton: FC = (props) => { }, []); return ( - - - +
+ + + +
); }; diff --git a/apps/app/components/account/google-login.tsx b/apps/app/components/account/google-login.tsx index 078eef518..478ffc67e 100644 --- a/apps/app/components/account/google-login.tsx +++ b/apps/app/components/account/google-login.tsx @@ -27,7 +27,7 @@ export const GoogleLoginButton: FC = (props) => { theme: "outline", size: "large", logo_alignment: "center", - width: document.getElementById("googleSignInButton")?.offsetWidth, + width: "410", text: "continue_with", } as GsiButtonConfiguration // customization attributes ); @@ -47,7 +47,7 @@ export const GoogleLoginButton: FC = (props) => { return ( <>