Merge pull request #3535 from makeplane/preview

release: 0.15-dev
This commit is contained in:
sriram veeraghanta 2024-02-01 15:01:49 +05:30 committed by GitHub
commit c6e3f1b932
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1586 changed files with 65121 additions and 53405 deletions

View File

@ -1,14 +1,12 @@
# Database Settings
PGUSER="plane"
PGPASSWORD="plane"
PGHOST="plane-db"
PGDATABASE="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
POSTGRES_USER="plane"
POSTGRES_PASSWORD="plane"
POSTGRES_DB="plane"
PGDATA="/var/lib/postgresql/data"
# Redis Settings
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/"
# AWS Settings
AWS_REGION=""

View File

@ -1,7 +1,8 @@
name: Bug report
description: Create a bug report to help us improve Plane
title: "[bug]: "
labels: [bug, need testing]
labels: [🐛bug]
assignees: [srinivaspendem, pushya-plane]
body:
- type: markdown
attributes:

View File

@ -1,7 +1,8 @@
name: Feature request
description: Suggest a feature to improve Plane
title: "[feature]: "
labels: [feature]
labels: [✨feature]
assignees: [srinivaspendem, pushya-plane]
body:
- type: markdown
attributes:

View File

@ -1,61 +1,30 @@
name: Branch Build
on:
pull_request:
types:
- closed
workflow_dispatch:
inputs:
branch_name:
description: "Branch Name"
required: true
default: "preview"
push:
branches:
- master
- preview
- qa
- develop
- release-*
release:
types: [released, prereleased]
env:
TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.event.release.target_commitish }}
TARGET_BRANCH: ${{ inputs.branch_name || github.ref_name || github.event.release.target_commitish }}
jobs:
branch_build_setup:
if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) || github.event_name == 'release' }}
name: Build-Push Web/Space/API/Proxy Docker Image
runs-on: ubuntu-20.04
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
- name: Uploading Proxy Source
uses: actions/upload-artifact@v3
with:
name: proxy-src-code
path: ./nginx
- name: Uploading Backend Source
uses: actions/upload-artifact@v3
with:
name: backend-src-code
path: ./apiserver
- name: Uploading Web Source
uses: actions/upload-artifact@v3
with:
name: web-src-code
path: |
./
!./apiserver
!./nginx
!./deploy
!./space
- name: Uploading Space Source
uses: actions/upload-artifact@v3
with:
name: space-src-code
path: |
./
!./apiserver
!./nginx
!./deploy
!./web
outputs:
gh_branch_name: ${{ env.TARGET_BRANCH }}
@ -63,33 +32,38 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
steps:
- name: Set Frontend Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
else
TAG=${{ env.FRONTEND_TAG }}
fi
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Web Source Code
uses: actions/download-artifact@v3
with:
name: web-src-code
- name: Check out the repo
uses: actions/checkout@v4.1.1
- name: Build and Push Frontend to Docker Container Registry
uses: docker/build-push-action@v4.0.0
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./web/Dockerfile.web
@ -105,33 +79,39 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
steps:
- name: Set Space Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
else
TAG=${{ env.SPACE_TAG }}
fi
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Space Source Code
uses: actions/download-artifact@v3
with:
name: space-src-code
- name: Check out the repo
uses: actions/checkout@v4.1.1
- name: Build and Push Space to Docker Hub
uses: docker/build-push-action@v4.0.0
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./space/Dockerfile.space
@ -147,36 +127,42 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
steps:
- name: Set Backend Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
else
TAG=${{ env.BACKEND_TAG }}
fi
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Backend Source Code
uses: actions/download-artifact@v3
with:
name: backend-src-code
- name: Check out the repo
uses: actions/checkout@v4.1.1
- name: Build and Push Backend to Docker Hub
uses: docker/build-push-action@v4.0.0
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./Dockerfile.api
context: ./apiserver
file: ./apiserver/Dockerfile.api
platforms: linux/amd64
push: true
tags: ${{ env.BACKEND_TAG }}
@ -189,37 +175,42 @@ jobs:
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }}
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
steps:
- name: Set Proxy Docker Tag
run: |
if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }}
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
else
TAG=${{ env.PROXY_TAG }}
fi
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
uses: docker/setup-buildx-action@v3.0.0
with:
platforms: linux/amd64,linux/arm64
buildkitd-flags: "--allow-insecure-entitlement security.insecure"
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Proxy Source Code
uses: actions/download-artifact@v3
with:
name: proxy-src-code
- name: Check out the repo
uses: actions/checkout@v4.1.1
- name: Build and Push Plane-Proxy to Docker Hub
uses: docker/build-push-action@v4.0.0
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./Dockerfile
context: ./nginx
file: ./nginx/Dockerfile
platforms: linux/amd64
tags: ${{ env.PROXY_TAG }}
push: true

View File

@ -25,7 +25,7 @@ jobs:
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v38
uses: tj-actions/changed-files@v41
with:
files_yaml: |
apiserver:

View File

@ -2,10 +2,10 @@ name: "CodeQL"
on:
push:
branches: [ 'develop', 'hot-fix', 'stage-release' ]
branches: [ 'develop', 'preview', 'master' ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ 'develop' ]
branches: [ 'develop', 'preview', 'master' ]
schedule:
- cron: '53 19 * * 5'

View File

@ -1,25 +1,23 @@
name: Create Sync Action
on:
pull_request:
workflow_dispatch:
push:
branches:
- preview
types:
- closed
env:
SOURCE_BRANCH_NAME: ${{github.event.pull_request.base.ref}}
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
jobs:
create_pr:
# Only run the job when a PR is merged
if: github.event.pull_request.merged == true
sync_changes:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout Code
uses: actions/checkout@v2
uses: actions/checkout@v4.1.1
with:
persist-credentials: false
fetch-depth: 0

View File

@ -37,7 +37,7 @@ Meet [Plane](https://plane.so). An open-source software development tool to mana
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting/docker-compose).
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
## ⚡️ Contributors Quick Start
@ -63,7 +63,7 @@ Thats it!
## 🍙 Self Hosting
For self hosting environment setup, visit the [Self Hosting](https://docs.plane.so/self-hosting/docker-compose) documentation page
For self hosting environment setup, visit the [Self Hosting](https://docs.plane.so/docker-compose) documentation page
## 🚀 Features

View File

@ -8,11 +8,11 @@ SENTRY_DSN=""
SENTRY_ENVIRONMENT="development"
# Database Settings
PGUSER="plane"
PGPASSWORD="plane"
PGHOST="plane-db"
PGDATABASE="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
POSTGRES_USER="plane"
POSTGRES_PASSWORD="plane"
POSTGRES_HOST="plane-db"
POSTGRES_DB="plane"
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
# Oauth variables
GOOGLE_CLIENT_ID=""
@ -39,9 +39,6 @@ OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # deprecated
# Github
GITHUB_CLIENT_SECRET="" # For fetching release notes
# Settings related to Docker
DOCKERIZED=1 # deprecated

View File

@ -33,15 +33,10 @@ RUN pip install -r requirements/local.txt --compile --no-cache-dir
RUN addgroup -S plane && \
adduser -S captain -G plane
RUN chown captain.plane /code
COPY . .
USER captain
# Add in Django deps and generate Django's static files
USER root
# RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
RUN chown -R captain.plane /code
RUN chmod -R +x /code/bin
RUN chmod -R 777 /code
USER captain

View File

@ -26,7 +26,9 @@ def update_description():
updated_issues.append(issue)
Issue.objects.bulk_update(
updated_issues, ["description_html", "description_stripped"], batch_size=100
updated_issues,
["description_html", "description_stripped"],
batch_size=100,
)
print("Success")
except Exception as e:
@ -40,7 +42,9 @@ def update_comments():
updated_issue_comments = []
for issue_comment in issue_comments:
issue_comment.comment_html = f"<p>{issue_comment.comment_stripped}</p>"
issue_comment.comment_html = (
f"<p>{issue_comment.comment_stripped}</p>"
)
updated_issue_comments.append(issue_comment)
IssueComment.objects.bulk_update(
@ -99,7 +103,9 @@ def updated_issue_sort_order():
issue.sort_order = issue.sequence_id * random.randint(100, 500)
updated_issues.append(issue)
Issue.objects.bulk_update(updated_issues, ["sort_order"], batch_size=100)
Issue.objects.bulk_update(
updated_issues, ["sort_order"], batch_size=100
)
print("Success")
except Exception as e:
print(e)
@ -137,7 +143,9 @@ def update_project_cover_images():
project.cover_image = project_cover_images[random.randint(0, 19)]
updated_projects.append(project)
Project.objects.bulk_update(updated_projects, ["cover_image"], batch_size=100)
Project.objects.bulk_update(
updated_projects, ["cover_image"], batch_size=100
)
print("Success")
except Exception as e:
print(e)
@ -186,7 +194,9 @@ def update_label_color():
def create_slack_integration():
try:
_ = Integration.objects.create(provider="slack", network=2, title="Slack")
_ = Integration.objects.create(
provider="slack", network=2, title="Slack"
)
print("Success")
except Exception as e:
print(e)
@ -212,12 +222,16 @@ def update_integration_verified():
def update_start_date():
try:
issues = Issue.objects.filter(state__group__in=["started", "completed"])
issues = Issue.objects.filter(
state__group__in=["started", "completed"]
)
updated_issues = []
for issue in issues:
issue.start_date = issue.created_at.date()
updated_issues.append(issue)
Issue.objects.bulk_update(updated_issues, ["start_date"], batch_size=500)
Issue.objects.bulk_update(
updated_issues, ["start_date"], batch_size=500
)
print("Success")
except Exception as e:
print(e)

3
apiserver/bin/beat Normal file → Executable file
View File

@ -2,4 +2,7 @@
set -e
python manage.py wait_for_db
# Wait for migrations
python manage.py wait_for_migrations
# Run the processes
celery -A plane beat -l info

View File

@ -1,7 +1,8 @@
#!/bin/bash
set -e
python manage.py wait_for_db
python manage.py migrate
# Wait for migrations
python manage.py wait_for_migrations
# Create the default bucket
#!/bin/bash

View File

@ -1,7 +1,8 @@
#!/bin/bash
set -e
python manage.py wait_for_db
python manage.py migrate
# Wait for migrations
python manage.py wait_for_migrations
# Create the default bucket
#!/bin/bash

View File

@ -2,4 +2,7 @@
set -e
python manage.py wait_for_db
# Wait for migrations
python manage.py wait_for_migrations
# Run the processes
celery -A plane worker -l info

View File

@ -2,10 +2,10 @@
import os
import sys
if __name__ == '__main__':
if __name__ == "__main__":
os.environ.setdefault(
'DJANGO_SETTINGS_MODULE',
'plane.settings.production')
"DJANGO_SETTINGS_MODULE", "plane.settings.production"
)
try:
from django.core.management import execute_from_command_line
except ImportError as exc:

View File

@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.14.0"
"version": "0.15.0"
}

View File

@ -1,3 +1,3 @@
from .celery import app as celery_app
__all__ = ('celery_app',)
__all__ = ("celery_app",)

View File

@ -2,4 +2,4 @@ from django.apps import AppConfig
class AnalyticsConfig(AppConfig):
name = 'plane.analytics'
name = "plane.analytics"

View File

@ -25,7 +25,10 @@ class APIKeyAuthentication(authentication.BaseAuthentication):
def validate_api_token(self, token):
try:
api_token = APIToken.objects.get(
Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)),
Q(
Q(expired_at__gt=timezone.now())
| Q(expired_at__isnull=True)
),
token=token,
is_active=True,
)

View File

@ -1,17 +1,18 @@
from rest_framework.throttling import SimpleRateThrottle
class ApiKeyRateThrottle(SimpleRateThrottle):
scope = 'api_key'
rate = '60/minute'
scope = "api_key"
rate = "60/minute"
def get_cache_key(self, request, view):
# Retrieve the API key from the request header
api_key = request.headers.get('X-Api-Key')
api_key = request.headers.get("X-Api-Key")
if not api_key:
return None # Allow the request if there's no API key
# Use the API key as part of the cache key
return f'{self.scope}:{api_key}'
return f"{self.scope}:{api_key}"
def allow_request(self, request, view):
allowed = super().allow_request(request, view)
@ -35,7 +36,7 @@ class ApiKeyRateThrottle(SimpleRateThrottle):
reset_time = int(now + self.duration)
# Add headers
request.META['X-RateLimit-Remaining'] = max(0, available)
request.META['X-RateLimit-Reset'] = reset_time
request.META["X-RateLimit-Remaining"] = max(0, available)
request.META["X-RateLimit-Reset"] = reset_time
return allowed

View File

@ -13,5 +13,9 @@ from .issue import (
)
from .state import StateLiteSerializer, StateSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
from .module import (
ModuleSerializer,
ModuleIssueSerializer,
ModuleLiteSerializer,
)
from .inbox import InboxIssueSerializer

View File

@ -97,9 +97,11 @@ class BaseSerializer(serializers.ModelSerializer):
exp_serializer = expansion[expand](
getattr(instance, expand)
)
response[expand] = exp_serializer.data
response[expand] = exp_serializer.data
else:
# You might need to handle this case differently
response[expand] = getattr(instance, f"{expand}_id", None)
response[expand] = getattr(
instance, f"{expand}_id", None
)
return response

View File

@ -23,7 +23,9 @@ class CycleSerializer(BaseSerializer):
and data.get("end_date", None) is not None
and data.get("start_date", None) > data.get("end_date", None)
):
raise serializers.ValidationError("Start date cannot exceed end date")
raise serializers.ValidationError(
"Start date cannot exceed end date"
)
return data
class Meta:
@ -55,7 +57,6 @@ class CycleIssueSerializer(BaseSerializer):
class CycleLiteSerializer(BaseSerializer):
class Meta:
model = Cycle
fields = "__all__"

View File

@ -2,8 +2,8 @@
from .base import BaseSerializer
from plane.db.models import InboxIssue
class InboxIssueSerializer(BaseSerializer):
class InboxIssueSerializer(BaseSerializer):
class Meta:
model = InboxIssue
fields = "__all__"

View File

@ -27,6 +27,7 @@ from .module import ModuleSerializer, ModuleLiteSerializer
from .user import UserLiteSerializer
from .state import StateLiteSerializer
class IssueSerializer(BaseSerializer):
assignees = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(
@ -66,12 +67,14 @@ class IssueSerializer(BaseSerializer):
and data.get("target_date", None) is not None
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
raise serializers.ValidationError(
"Start date cannot exceed target date"
)
try:
if(data.get("description_html", None) is not None):
if data.get("description_html", None) is not None:
parsed = html.fromstring(data["description_html"])
parsed_str = html.tostring(parsed, encoding='unicode')
parsed_str = html.tostring(parsed, encoding="unicode")
data["description_html"] = parsed_str
except Exception as e:
@ -96,7 +99,8 @@ class IssueSerializer(BaseSerializer):
if (
data.get("state")
and not State.objects.filter(
project_id=self.context.get("project_id"), pk=data.get("state").id
project_id=self.context.get("project_id"),
pk=data.get("state").id,
).exists()
):
raise serializers.ValidationError(
@ -107,7 +111,8 @@ class IssueSerializer(BaseSerializer):
if (
data.get("parent")
and not Issue.objects.filter(
workspace_id=self.context.get("workspace_id"), pk=data.get("parent").id
workspace_id=self.context.get("workspace_id"),
pk=data.get("parent").id,
).exists()
):
raise serializers.ValidationError(
@ -238,9 +243,13 @@ class IssueSerializer(BaseSerializer):
]
if "labels" in self.fields:
if "labels" in self.expand:
data["labels"] = LabelSerializer(instance.labels.all(), many=True).data
data["labels"] = LabelSerializer(
instance.labels.all(), many=True
).data
else:
data["labels"] = [str(label.id) for label in instance.labels.all()]
data["labels"] = [
str(label.id) for label in instance.labels.all()
]
return data
@ -278,7 +287,8 @@ class IssueLinkSerializer(BaseSerializer):
# Validation if url already exists
def create(self, validated_data):
if IssueLink.objects.filter(
url=validated_data.get("url"), issue_id=validated_data.get("issue_id")
url=validated_data.get("url"),
issue_id=validated_data.get("issue_id"),
).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
@ -324,9 +334,9 @@ class IssueCommentSerializer(BaseSerializer):
def validate(self, data):
try:
if(data.get("comment_html", None) is not None):
if data.get("comment_html", None) is not None:
parsed = html.fromstring(data["comment_html"])
parsed_str = html.tostring(parsed, encoding='unicode')
parsed_str = html.tostring(parsed, encoding="unicode")
data["comment_html"] = parsed_str
except Exception as e:
@ -362,7 +372,6 @@ class ModuleIssueSerializer(BaseSerializer):
class LabelLiteSerializer(BaseSerializer):
class Meta:
model = Label
fields = [

View File

@ -52,7 +52,9 @@ class ModuleSerializer(BaseSerializer):
and data.get("target_date", None) is not None
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
raise serializers.ValidationError(
"Start date cannot exceed target date"
)
if data.get("members", []):
data["members"] = ProjectMember.objects.filter(
@ -146,7 +148,8 @@ class ModuleLinkSerializer(BaseSerializer):
# Validation if url already exists
def create(self, validated_data):
if ModuleLink.objects.filter(
url=validated_data.get("url"), module_id=validated_data.get("module_id")
url=validated_data.get("url"),
module_id=validated_data.get("module_id"),
).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
@ -155,7 +158,6 @@ class ModuleLinkSerializer(BaseSerializer):
class ModuleLiteSerializer(BaseSerializer):
class Meta:
model = Module
fields = "__all__"

View File

@ -2,12 +2,17 @@
from rest_framework import serializers
# Module imports
from plane.db.models import Project, ProjectIdentifier, WorkspaceMember, State, Estimate
from plane.db.models import (
Project,
ProjectIdentifier,
WorkspaceMember,
State,
Estimate,
)
from .base import BaseSerializer
class ProjectSerializer(BaseSerializer):
total_members = serializers.IntegerField(read_only=True)
total_cycles = serializers.IntegerField(read_only=True)
total_modules = serializers.IntegerField(read_only=True)
@ -21,7 +26,7 @@ class ProjectSerializer(BaseSerializer):
fields = "__all__"
read_only_fields = [
"id",
'emoji',
"emoji",
"workspace",
"created_at",
"updated_at",
@ -59,12 +64,16 @@ class ProjectSerializer(BaseSerializer):
def create(self, validated_data):
identifier = validated_data.get("identifier", "").strip().upper()
if identifier == "":
raise serializers.ValidationError(detail="Project Identifier is required")
raise serializers.ValidationError(
detail="Project Identifier is required"
)
if ProjectIdentifier.objects.filter(
name=identifier, workspace_id=self.context["workspace_id"]
).exists():
raise serializers.ValidationError(detail="Project Identifier is taken")
raise serializers.ValidationError(
detail="Project Identifier is taken"
)
project = Project.objects.create(
**validated_data, workspace_id=self.context["workspace_id"]

View File

@ -7,9 +7,9 @@ class StateSerializer(BaseSerializer):
def validate(self, data):
# If the default is being provided then make all other states default False
if data.get("default", False):
State.objects.filter(project_id=self.context.get("project_id")).update(
default=False
)
State.objects.filter(
project_id=self.context.get("project_id")
).update(default=False)
return data
class Meta:

View File

@ -5,6 +5,7 @@ from .base import BaseSerializer
class WorkspaceLiteSerializer(BaseSerializer):
"""Lite serializer with only required fields"""
class Meta:
model = Workspace
fields = [

View File

@ -3,7 +3,7 @@ from django.urls import path
from plane.api.views import ProjectAPIEndpoint
urlpatterns = [
path(
path(
"workspaces/<str:slug>/projects/",
ProjectAPIEndpoint.as_view(),
name="project",

View File

@ -41,7 +41,9 @@ class WebhookMixin:
bulk = False
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
response = super().finalize_response(
request, response, *args, **kwargs
)
# Check for the case should webhook be sent
if (
@ -104,15 +106,14 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
)
if isinstance(e, ObjectDoesNotExist):
model_name = str(exc).split(" matching query does not exist.")[0]
return Response(
{"error": f"{model_name} does not exist."},
{"error": f"The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
if isinstance(e, KeyError):
return Response(
{"error": f"key {e} does not exist"},
{"error": f" The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
@ -140,7 +141,9 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
def finalize_response(self, request, response, *args, **kwargs):
# Call super to get the default response
response = super().finalize_response(request, response, *args, **kwargs)
response = super().finalize_response(
request, response, *args, **kwargs
)
# Add custom headers if they exist in the request META
ratelimit_remaining = request.META.get("X-RateLimit-Remaining")
@ -164,13 +167,17 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
@property
def fields(self):
fields = [
field for field in self.request.GET.get("fields", "").split(",") if field
field
for field in self.request.GET.get("fields", "").split(",")
if field
]
return fields if fields else None
@property
def expand(self):
expand = [
expand for expand in self.request.GET.get("expand", "").split(",") if expand
expand
for expand in self.request.GET.get("expand", "").split(",")
if expand
]
return expand if expand else None

View File

@ -12,7 +12,13 @@ from rest_framework import status
# Module imports
from .base import BaseAPIView, WebhookMixin
from plane.db.models import Cycle, Issue, CycleIssue, IssueLink, IssueAttachment
from plane.db.models import (
Cycle,
Issue,
CycleIssue,
IssueLink,
IssueAttachment,
)
from plane.app.permissions import ProjectEntityPermission
from plane.api.serializers import (
CycleSerializer,
@ -102,7 +108,9 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
),
)
)
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
.annotate(
total_estimates=Sum("issue_cycle__issue__estimate_point")
)
.annotate(
completed_estimates=Sum(
"issue_cycle__issue__estimate_point",
@ -201,7 +209,8 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
# Incomplete Cycles
if cycle_view == "incomplete":
queryset = queryset.filter(
Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True),
Q(end_date__gte=timezone.now().date())
| Q(end_date__isnull=True),
)
return self.paginate(
request=request,
@ -238,8 +247,12 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
project_id=project_id,
owned_by=request.user,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(
serializer.data, status=status.HTTP_201_CREATED
)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
else:
return Response(
{
@ -249,15 +262,22 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
)
def patch(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
request_data = request.data
if cycle.end_date is not None and cycle.end_date < timezone.now().date():
if (
cycle.end_date is not None
and cycle.end_date < timezone.now().date()
):
if "sort_order" in request_data:
# Can only change sort order
request_data = {
"sort_order": request_data.get("sort_order", cycle.sort_order)
"sort_order": request_data.get(
"sort_order", cycle.sort_order
)
}
else:
return Response(
@ -275,11 +295,13 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
def delete(self, request, slug, project_id, pk):
cycle_issues = list(
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
"issue", flat=True
)
CycleIssue.objects.filter(
cycle_id=self.kwargs.get("pk")
).values_list("issue", flat=True)
)
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
issue_activity.delay(
type="cycle.activity.deleted",
@ -319,7 +341,9 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
def get_queryset(self):
return (
CycleIssue.objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id"))
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("issue_id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@ -342,7 +366,9 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@ -364,7 +390,9 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@ -387,14 +415,18 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
if not issues:
return Response(
{"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
if cycle.end_date is not None and cycle.end_date < timezone.now().date():
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"
@ -479,7 +511,10 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
def delete(self, request, slug, project_id, cycle_id, issue_id):
cycle_issue = CycleIssue.objects.get(
issue_id=issue_id, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,
cycle_id=cycle_id,
)
issue_id = cycle_issue.issue_id
cycle_issue.delete()

View File

@ -14,7 +14,14 @@ from rest_framework.response import Response
from .base import BaseAPIView
from plane.app.permissions import ProjectLitePermission
from plane.api.serializers import InboxIssueSerializer, IssueSerializer
from plane.db.models import InboxIssue, Issue, State, ProjectMember, Project, Inbox
from plane.db.models import (
InboxIssue,
Issue,
State,
ProjectMember,
Project,
Inbox,
)
from plane.bgtasks.issue_activites_task import issue_activity
@ -43,7 +50,8 @@ class InboxIssueAPIEndpoint(BaseAPIView):
).first()
project = Project.objects.get(
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
workspace__slug=self.kwargs.get("slug"),
pk=self.kwargs.get("project_id"),
)
if inbox is None and not project.inbox_view:
@ -51,7 +59,8 @@ class InboxIssueAPIEndpoint(BaseAPIView):
return (
InboxIssue.objects.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
Q(snoozed_till__gte=timezone.now())
| Q(snoozed_till__isnull=True),
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
inbox_id=inbox.id,
@ -87,7 +96,8 @@ class InboxIssueAPIEndpoint(BaseAPIView):
def post(self, request, slug, project_id):
if not request.data.get("issue", {}).get("name", False):
return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Name is required"},
status=status.HTTP_400_BAD_REQUEST,
)
inbox = Inbox.objects.filter(
@ -117,7 +127,8 @@ class InboxIssueAPIEndpoint(BaseAPIView):
"none",
]:
return Response(
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Invalid priority"},
status=status.HTTP_400_BAD_REQUEST,
)
# Create or get state
@ -222,10 +233,14 @@ class InboxIssueAPIEndpoint(BaseAPIView):
"description_html": issue_data.get(
"description_html", issue.description_html
),
"description": issue_data.get("description", issue.description),
"description": issue_data.get(
"description", issue.description
),
}
issue_serializer = IssueSerializer(issue, data=issue_data, partial=True)
issue_serializer = IssueSerializer(
issue, data=issue_data, partial=True
)
if issue_serializer.is_valid():
current_instance = issue
@ -266,7 +281,9 @@ class InboxIssueAPIEndpoint(BaseAPIView):
project_id=project_id,
)
state = State.objects.filter(
group="cancelled", workspace__slug=slug, project_id=project_id
group="cancelled",
workspace__slug=slug,
project_id=project_id,
).first()
if state is not None:
issue.state = state
@ -284,17 +301,22 @@ class InboxIssueAPIEndpoint(BaseAPIView):
if issue.state.name == "Triage":
# Move to default state
state = State.objects.filter(
workspace__slug=slug, project_id=project_id, default=True
workspace__slug=slug,
project_id=project_id,
default=True,
).first()
if state is not None:
issue.state = state
issue.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
else:
return Response(
InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK
InboxIssueSerializer(inbox_issue).data,
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, issue_id):

View File

@ -67,7 +67,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
def get_queryset(self):
return (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@ -86,7 +88,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
def get(self, request, slug, project_id, pk=None):
if pk:
issue = Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@ -102,7 +106,13 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at")
@ -117,7 +127,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@ -127,7 +139,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order if order_by_param == "priority" else priority_order[::-1]
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
@ -175,7 +189,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
@ -209,7 +225,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
# Track the issue
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
requested_data=json.dumps(
self.request.data, cls=DjangoJSONEncoder
),
actor_id=str(request.user.id),
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
@ -220,7 +238,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def patch(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
project = Project.objects.get(pk=project_id)
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
@ -250,7 +270,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
@ -297,11 +319,17 @@ class LabelAPIEndpoint(BaseAPIView):
serializer = LabelSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(
serializer.data, status=status.HTTP_201_CREATED
)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
except IntegrityError:
return Response(
{"error": "Label with the same name already exists in the project"},
{
"error": "Label with the same name already exists in the project"
},
status=status.HTTP_400_BAD_REQUEST,
)
@ -318,7 +346,11 @@ class LabelAPIEndpoint(BaseAPIView):
).data,
)
label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(label, fields=self.fields, expand=self.expand,)
serializer = LabelSerializer(
label,
fields=self.fields,
expand=self.expand,
)
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, project_id, pk=None):
@ -329,7 +361,6 @@ class LabelAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, project_id, pk=None):
label = self.get_queryset().get(pk=pk)
label.delete()
@ -395,7 +426,9 @@ class IssueLinkAPIEndpoint(BaseAPIView):
)
issue_activity.delay(
type="link.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
requested_data=json.dumps(
serializer.data, cls=DjangoJSONEncoder
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
@ -407,14 +440,19 @@ class IssueLinkAPIEndpoint(BaseAPIView):
def patch(self, request, slug, project_id, issue_id, pk):
issue_link = IssueLink.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
pk=pk,
)
requested_data = json.dumps(request.data, cls=DjangoJSONEncoder)
current_instance = json.dumps(
IssueLinkSerializer(issue_link).data,
cls=DjangoJSONEncoder,
)
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
serializer = IssueLinkSerializer(
issue_link, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
@ -431,7 +469,10 @@ class IssueLinkAPIEndpoint(BaseAPIView):
def delete(self, request, slug, project_id, issue_id, pk):
issue_link = IssueLink.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
pk=pk,
)
current_instance = json.dumps(
IssueLinkSerializer(issue_link).data,
@ -466,7 +507,9 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
def get_queryset(self):
return (
IssueComment.objects.filter(workspace__slug=self.kwargs.get("slug"))
IssueComment.objects.filter(
workspace__slug=self.kwargs.get("slug")
)
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user)
@ -518,7 +561,9 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
)
issue_activity.delay(
type="comment.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
requested_data=json.dumps(
serializer.data, cls=DjangoJSONEncoder
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
@ -530,7 +575,10 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
def patch(self, request, slug, project_id, issue_id, pk):
issue_comment = IssueComment.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
pk=pk,
)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
current_instance = json.dumps(
@ -556,7 +604,10 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
def delete(self, request, slug, project_id, issue_id, pk):
issue_comment = IssueComment.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
pk=pk,
)
current_instance = json.dumps(
IssueCommentSerializer(issue_comment).data,

View File

@ -55,7 +55,9 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
.prefetch_related(
Prefetch(
"link_module",
queryset=ModuleLink.objects.select_related("module", "created_by"),
queryset=ModuleLink.objects.select_related(
"module", "created_by"
),
)
)
.annotate(
@ -122,7 +124,13 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
def post(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
serializer = ModuleSerializer(data=request.data, context={"project_id": project_id, "workspace_id": project.workspace_id})
serializer = ModuleSerializer(
data=request.data,
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
},
)
if serializer.is_valid():
serializer.save()
module = Module.objects.get(pk=serializer.data["id"])
@ -131,8 +139,15 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def patch(self, request, slug, project_id, pk):
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
serializer = ModuleSerializer(module, data=request.data, context={"project_id": project_id}, partial=True)
module = Module.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
)
serializer = ModuleSerializer(
module,
data=request.data,
context={"project_id": project_id},
partial=True,
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
@ -162,9 +177,13 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
)
def delete(self, request, slug, project_id, pk):
module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
module = Module.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
module_issues = list(
ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True)
ModuleIssue.objects.filter(module_id=pk).values_list(
"issue", flat=True
)
)
issue_activity.delay(
type="module.activity.deleted",
@ -204,7 +223,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
def get_queryset(self):
return (
ModuleIssue.objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue"))
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("issue")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@ -228,7 +249,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
issues = (
Issue.issue_objects.filter(issue_module__module_id=module_id)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@ -250,7 +273,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@ -271,7 +296,8 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
issues = request.data.get("issues", [])
if not len(issues):
return Response(
{"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
)
module = Module.objects.get(
workspace__slug=slug, project_id=project_id, pk=module_id
@ -354,7 +380,10 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
def delete(self, request, slug, project_id, module_id, issue_id):
module_issue = ModuleIssue.objects.get(
workspace__slug=slug, project_id=project_id, module_id=module_id, issue_id=issue_id
workspace__slug=slug,
project_id=project_id,
module_id=module_id,
issue_id=issue_id,
)
module_issue.delete()
issue_activity.delay(

View File

@ -39,9 +39,15 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
def get_queryset(self):
return (
Project.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(Q(project_projectmember__member=self.request.user) | Q(network=2))
.filter(
Q(project_projectmember__member=self.request.user)
| Q(network=2)
)
.select_related(
"workspace", "workspace__owner", "default_assignee", "project_lead"
"workspace",
"workspace__owner",
"default_assignee",
"project_lead",
)
.annotate(
is_member=Exists(
@ -120,11 +126,18 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
request=request,
queryset=(projects),
on_results=lambda projects: ProjectSerializer(
projects, many=True, fields=self.fields, expand=self.expand,
projects,
many=True,
fields=self.fields,
expand=self.expand,
).data,
)
project = self.get_queryset().get(workspace__slug=slug, pk=project_id)
serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand,)
serializer = ProjectSerializer(
project,
fields=self.fields,
expand=self.expand,
)
return Response(serializer.data, status=status.HTTP_200_OK)
def post(self, request, slug):
@ -138,7 +151,9 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
# Add the user as Administrator to the project
project_member = ProjectMember.objects.create(
project_id=serializer.data["id"], member=request.user, role=20
project_id=serializer.data["id"],
member=request.user,
role=20,
)
# Also create the issue property for the user
_ = IssueProperty.objects.create(
@ -211,9 +226,15 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
]
)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
project = (
self.get_queryset()
.filter(pk=serializer.data["id"])
.first()
)
serializer = ProjectSerializer(project)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
serializer.data, status=status.HTTP_201_CREATED
)
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST,
@ -226,7 +247,8 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
)
except Workspace.DoesNotExist as e:
return Response(
{"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND
{"error": "Workspace does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
except ValidationError as e:
return Response(
@ -250,7 +272,9 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
serializer.save()
if serializer.data["inbox_view"]:
Inbox.objects.get_or_create(
name=f"{project.name} Inbox", project=project, is_default=True
name=f"{project.name} Inbox",
project=project,
is_default=True,
)
# Create the triage state in Backlog group
@ -262,10 +286,16 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
color="#ff7700",
)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
project = (
self.get_queryset()
.filter(pk=serializer.data["id"])
.first()
)
serializer = ProjectSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
@ -274,7 +304,8 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
)
except (Project.DoesNotExist, Workspace.DoesNotExist):
return Response(
{"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND
{"error": "Project does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
except ValidationError as e:
return Response(

View File

@ -34,7 +34,9 @@ class StateAPIEndpoint(BaseAPIView):
)
def post(self, request, slug, project_id):
serializer = StateSerializer(data=request.data, context={"project_id": project_id})
serializer = StateSerializer(
data=request.data, context={"project_id": project_id}
)
if serializer.is_valid():
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_200_OK)
@ -64,14 +66,19 @@ class StateAPIEndpoint(BaseAPIView):
)
if state.default:
return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "Default state cannot be deleted"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=state_id).exists()
if issue_exist:
return Response(
{"error": "The state is not empty, only empty states can be deleted"},
{
"error": "The state is not empty, only empty states can be deleted"
},
status=status.HTTP_400_BAD_REQUEST,
)
@ -79,7 +86,9 @@ class StateAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, project_id, state_id=None):
state = State.objects.get(workspace__slug=slug, project_id=project_id, pk=state_id)
state = State.objects.get(
workspace__slug=slug, project_id=project_id, pk=state_id
)
serializer = StateSerializer(state, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()

View File

@ -25,7 +25,10 @@ class APIKeyAuthentication(authentication.BaseAuthentication):
def validate_api_token(self, token):
try:
api_token = APIToken.objects.get(
Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)),
Q(
Q(expired_at__gt=timezone.now())
| Q(expired_at__isnull=True)
),
token=token,
is_active=True,
)

View File

@ -1,4 +1,3 @@
from .workspace import (
WorkSpaceBasePermission,
WorkspaceOwnerPermission,
@ -13,5 +12,3 @@ from .project import (
ProjectMemberPermission,
ProjectLitePermission,
)

View File

@ -17,6 +17,7 @@ from .workspace import (
WorkspaceThemeSerializer,
WorkspaceMemberAdminSerializer,
WorkspaceMemberMeSerializer,
WorkspaceUserPropertiesSerializer,
)
from .project import (
ProjectSerializer,
@ -31,14 +32,20 @@ from .project import (
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
ProjectPublicMemberSerializer,
ProjectMemberRoleSerializer,
)
from .state import StateSerializer, StateLiteSerializer
from .view import GlobalViewSerializer, IssueViewSerializer, IssueViewFavoriteSerializer
from .view import (
GlobalViewSerializer,
IssueViewSerializer,
IssueViewFavoriteSerializer,
)
from .cycle import (
CycleSerializer,
CycleIssueSerializer,
CycleFavoriteSerializer,
CycleWriteSerializer,
CycleUserPropertiesSerializer,
)
from .asset import FileAssetSerializer
from .issue import (
@ -69,6 +76,7 @@ from .module import (
ModuleIssueSerializer,
ModuleLinkSerializer,
ModuleFavoriteSerializer,
ModuleUserPropertiesSerializer,
)
from .api import APITokenSerializer, APITokenReadSerializer
@ -85,20 +93,33 @@ from .integration import (
from .importer import ImporterSerializer
from .page import PageSerializer, PageLogSerializer, SubPageSerializer, PageFavoriteSerializer
from .page import (
PageSerializer,
PageLogSerializer,
SubPageSerializer,
PageFavoriteSerializer,
)
from .estimate import (
EstimateSerializer,
EstimatePointSerializer,
EstimateReadSerializer,
WorkspaceEstimateSerializer,
)
from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSerializer
from .inbox import (
InboxSerializer,
InboxIssueSerializer,
IssueStateInboxSerializer,
InboxIssueLiteSerializer,
)
from .analytic import AnalyticViewSerializer
from .notification import NotificationSerializer
from .notification import NotificationSerializer, UserNotificationPreferenceSerializer
from .exporter import ExporterHistorySerializer
from .webhook import WebhookSerializer, WebhookLogSerializer
from .dashboard import DashboardSerializer, WidgetSerializer

View File

@ -3,7 +3,6 @@ from plane.db.models import APIToken, APIActivityLog
class APITokenSerializer(BaseSerializer):
class Meta:
model = APIToken
fields = "__all__"
@ -18,14 +17,12 @@ class APITokenSerializer(BaseSerializer):
class APITokenReadSerializer(BaseSerializer):
class Meta:
model = APIToken
exclude = ('token',)
exclude = ("token",)
class APIActivityLogSerializer(BaseSerializer):
class Meta:
model = APIActivityLog
fields = "__all__"

View File

@ -4,16 +4,17 @@ from rest_framework import serializers
class BaseSerializer(serializers.ModelSerializer):
id = serializers.PrimaryKeyRelatedField(read_only=True)
class DynamicBaseSerializer(BaseSerializer):
class DynamicBaseSerializer(BaseSerializer):
def __init__(self, *args, **kwargs):
# If 'fields' is provided in the arguments, remove it and store it separately.
# This is done so as not to pass this custom argument up to the superclass.
fields = kwargs.pop("fields", None)
fields = kwargs.pop("fields", [])
self.expand = kwargs.pop("expand", []) or []
fields = self.expand
# Call the initialization of the superclass.
super().__init__(*args, **kwargs)
# If 'fields' was provided, filter the fields of the serializer accordingly.
if fields is not None:
self.fields = self._filter_fields(fields)
@ -47,12 +48,101 @@ class DynamicBaseSerializer(BaseSerializer):
elif isinstance(item, dict):
allowed.append(list(item.keys())[0])
# Convert the current serializer's fields and the allowed fields to sets.
existing = set(self.fields)
allowed = set(allowed)
for field in allowed:
if field not in self.fields:
from . import (
WorkspaceLiteSerializer,
ProjectLiteSerializer,
UserLiteSerializer,
StateLiteSerializer,
IssueSerializer,
LabelSerializer,
CycleIssueSerializer,
IssueFlatSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer
)
# Remove fields from the serializer that aren't in the 'allowed' list.
for field_name in (existing - allowed):
self.fields.pop(field_name)
# Expansion mapper
expansion = {
"user": UserLiteSerializer,
"workspace": WorkspaceLiteSerializer,
"project": ProjectLiteSerializer,
"default_assignee": UserLiteSerializer,
"project_lead": UserLiteSerializer,
"state": StateLiteSerializer,
"created_by": UserLiteSerializer,
"issue": IssueSerializer,
"actor": UserLiteSerializer,
"owned_by": UserLiteSerializer,
"members": UserLiteSerializer,
"assignees": UserLiteSerializer,
"labels": LabelSerializer,
"issue_cycle": CycleIssueSerializer,
"parent": IssueSerializer,
"issue_relation": IssueRelationSerializer,
"issue_inbox" : InboxIssueLiteSerializer,
}
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation", "issue_inbox"] else False)
return self.fields
def to_representation(self, instance):
response = super().to_representation(instance)
# Ensure 'expand' is iterable before processing
if self.expand:
for expand in self.expand:
if expand in self.fields:
# Import all the expandable serializers
from . import (
WorkspaceLiteSerializer,
ProjectLiteSerializer,
UserLiteSerializer,
StateLiteSerializer,
IssueSerializer,
LabelSerializer,
CycleIssueSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer
)
# Expansion mapper
expansion = {
"user": UserLiteSerializer,
"workspace": WorkspaceLiteSerializer,
"project": ProjectLiteSerializer,
"default_assignee": UserLiteSerializer,
"project_lead": UserLiteSerializer,
"state": StateLiteSerializer,
"created_by": UserLiteSerializer,
"issue": IssueSerializer,
"actor": UserLiteSerializer,
"owned_by": UserLiteSerializer,
"members": UserLiteSerializer,
"assignees": UserLiteSerializer,
"labels": LabelSerializer,
"issue_cycle": CycleIssueSerializer,
"parent": IssueSerializer,
"issue_relation": IssueRelationSerializer,
"issue_inbox" : InboxIssueLiteSerializer,
}
# Check if field in expansion then expand the field
if expand in expansion:
if isinstance(response.get(expand), list):
exp_serializer = expansion[expand](
getattr(instance, expand), many=True
)
else:
exp_serializer = expansion[expand](
getattr(instance, expand)
)
response[expand] = exp_serializer.data
else:
# You might need to handle this case differently
response[expand] = getattr(
instance, f"{expand}_id", None
)
return response

View File

@ -7,7 +7,12 @@ from .user import UserLiteSerializer
from .issue import IssueStateSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import Cycle, CycleIssue, CycleFavorite
from plane.db.models import (
Cycle,
CycleIssue,
CycleFavorite,
CycleUserProperties,
)
class CycleWriteSerializer(BaseSerializer):
@ -17,7 +22,9 @@ class CycleWriteSerializer(BaseSerializer):
and data.get("end_date", None) is not None
and data.get("start_date", None) > data.get("end_date", None)
):
raise serializers.ValidationError("Start date cannot exceed end date")
raise serializers.ValidationError(
"Start date cannot exceed end date"
)
return data
class Meta:
@ -26,7 +33,6 @@ class CycleWriteSerializer(BaseSerializer):
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)
@ -38,7 +44,9 @@ class CycleSerializer(BaseSerializer):
total_estimates = serializers.IntegerField(read_only=True)
completed_estimates = serializers.IntegerField(read_only=True)
started_estimates = serializers.IntegerField(read_only=True)
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
project_detail = ProjectLiteSerializer(read_only=True, source="project")
status = serializers.CharField(read_only=True)
@ -48,7 +56,9 @@ class CycleSerializer(BaseSerializer):
and data.get("end_date", None) is not None
and data.get("start_date", None) > data.get("end_date", None)
):
raise serializers.ValidationError("Start date cannot exceed end date")
raise serializers.ValidationError(
"Start date cannot exceed end date"
)
return data
def get_assignees(self, obj):
@ -106,3 +116,14 @@ class CycleFavoriteSerializer(BaseSerializer):
"project",
"user",
]
class CycleUserPropertiesSerializer(BaseSerializer):
class Meta:
model = CycleUserProperties
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"cycle" "user",
]

View File

@ -0,0 +1,26 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import Dashboard, Widget
# Third party frameworks
from rest_framework import serializers
class DashboardSerializer(BaseSerializer):
class Meta:
model = Dashboard
fields = "__all__"
class WidgetSerializer(BaseSerializer):
is_visible = serializers.BooleanField(read_only=True)
widget_filters = serializers.JSONField(read_only=True)
class Meta:
model = Widget
fields = [
"id",
"key",
"is_visible",
"widget_filters"
]

View File

@ -2,12 +2,18 @@
from .base import BaseSerializer
from plane.db.models import Estimate, EstimatePoint
from plane.app.serializers import WorkspaceLiteSerializer, ProjectLiteSerializer
from plane.app.serializers import (
WorkspaceLiteSerializer,
ProjectLiteSerializer,
)
from rest_framework import serializers
class EstimateSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
@ -20,13 +26,14 @@ class EstimateSerializer(BaseSerializer):
class EstimatePointSerializer(BaseSerializer):
def validate(self, data):
if not data:
raise serializers.ValidationError("Estimate points are required")
value = data.get("value")
if value and len(value) > 20:
raise serializers.ValidationError("Value can't be more than 20 characters")
raise serializers.ValidationError(
"Value can't be more than 20 characters"
)
return data
class Meta:
@ -41,7 +48,9 @@ class EstimatePointSerializer(BaseSerializer):
class EstimateReadSerializer(BaseSerializer):
points = EstimatePointSerializer(read_only=True, many=True)
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
@ -52,3 +61,18 @@ class EstimateReadSerializer(BaseSerializer):
"name",
"description",
]
class WorkspaceEstimateSerializer(BaseSerializer):
points = EstimatePointSerializer(read_only=True, many=True)
class Meta:
model = Estimate
fields = "__all__"
read_only_fields = [
"points",
"name",
"description",
]

View File

@ -5,7 +5,9 @@ from .user import UserLiteSerializer
class ExporterHistorySerializer(BaseSerializer):
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
initiated_by_detail = UserLiteSerializer(
source="initiated_by", read_only=True
)
class Meta:
model = ExporterHistory

View File

@ -7,9 +7,13 @@ from plane.db.models import Importer
class ImporterSerializer(BaseSerializer):
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
initiated_by_detail = UserLiteSerializer(
source="initiated_by", read_only=True
)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
workspace_detail = WorkspaceLiteSerializer(
source="workspace", read_only=True
)
class Meta:
model = Importer

View File

@ -46,10 +46,13 @@ class InboxIssueLiteSerializer(BaseSerializer):
class IssueStateInboxSerializer(BaseSerializer):
state_detail = StateLiteSerializer(read_only=True, source="state")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
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)
bridge_id = serializers.UUIDField(read_only=True)
issue_inbox = InboxIssueLiteSerializer(read_only=True, many=True)
class Meta:

View File

@ -13,7 +13,9 @@ class IntegrationSerializer(BaseSerializer):
class WorkspaceIntegrationSerializer(BaseSerializer):
integration_detail = IntegrationSerializer(read_only=True, source="integration")
integration_detail = IntegrationSerializer(
read_only=True, source="integration"
)
class Meta:
model = WorkspaceIntegration

View File

@ -30,6 +30,8 @@ from plane.db.models import (
CommentReaction,
IssueVote,
IssueRelation,
State,
Project,
)
@ -69,19 +71,26 @@ class IssueProjectLiteSerializer(BaseSerializer):
##TODO: Find a better way to write this serializer
## Find a better approach to save manytomany?
class IssueCreateSerializer(BaseSerializer):
state_detail = StateSerializer(read_only=True, source="state")
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
assignees = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
# ids
state_id = serializers.PrimaryKeyRelatedField(
source="state",
queryset=State.objects.all(),
required=False,
allow_null=True,
)
parent_id = serializers.PrimaryKeyRelatedField(
source="parent",
queryset=Issue.objects.all(),
required=False,
allow_null=True,
)
label_ids = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True,
required=False,
)
labels = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
assignee_ids = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
)
@ -100,8 +109,10 @@ class IssueCreateSerializer(BaseSerializer):
def to_representation(self, instance):
data = super().to_representation(instance)
data['assignees'] = [str(assignee.id) for assignee in instance.assignees.all()]
data['labels'] = [str(label.id) for label in instance.labels.all()]
assignee_ids = self.initial_data.get("assignee_ids")
data["assignee_ids"] = assignee_ids if assignee_ids else []
label_ids = self.initial_data.get("label_ids")
data["label_ids"] = label_ids if label_ids else []
return data
def validate(self, data):
@ -110,12 +121,14 @@ class IssueCreateSerializer(BaseSerializer):
and data.get("target_date", None) is not None
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
raise serializers.ValidationError(
"Start date cannot exceed target date"
)
return data
def create(self, validated_data):
assignees = validated_data.pop("assignees", None)
labels = validated_data.pop("labels", None)
assignees = validated_data.pop("assignee_ids", None)
labels = validated_data.pop("label_ids", None)
project_id = self.context["project_id"]
workspace_id = self.context["workspace_id"]
@ -173,8 +186,8 @@ class IssueCreateSerializer(BaseSerializer):
return issue
def update(self, instance, validated_data):
assignees = validated_data.pop("assignees", None)
labels = validated_data.pop("labels", None)
assignees = validated_data.pop("assignee_ids", None)
labels = validated_data.pop("label_ids", None)
# Related models
project_id = instance.project_id
@ -225,14 +238,15 @@ class IssueActivitySerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
class Meta:
model = IssueActivity
fields = "__all__"
class IssuePropertySerializer(BaseSerializer):
class Meta:
model = IssueProperty
@ -245,12 +259,17 @@ class IssuePropertySerializer(BaseSerializer):
class LabelSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
class Meta:
model = Label
fields = "__all__"
fields = [
"parent",
"name",
"color",
"id",
"project_id",
"workspace_id",
"sort_order",
]
read_only_fields = [
"workspace",
"project",
@ -268,7 +287,6 @@ class LabelLiteSerializer(BaseSerializer):
class IssueLabelSerializer(BaseSerializer):
class Meta:
model = IssueLabel
fields = "__all__"
@ -279,33 +297,50 @@ class IssueLabelSerializer(BaseSerializer):
class IssueRelationSerializer(BaseSerializer):
issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue")
id = serializers.UUIDField(source="related_issue.id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(
source="related_issue.project_id", read_only=True
)
sequence_id = serializers.IntegerField(
source="related_issue.sequence_id", read_only=True
)
name = serializers.CharField(source="related_issue.name", read_only=True)
relation_type = serializers.CharField(read_only=True)
class Meta:
model = IssueRelation
fields = [
"issue_detail",
"id",
"project_id",
"sequence_id",
"relation_type",
"related_issue",
"issue",
"id"
"name",
]
read_only_fields = [
"workspace",
"project",
]
class RelatedIssueSerializer(BaseSerializer):
issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue")
id = serializers.UUIDField(source="issue.id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(
source="issue.project_id", read_only=True
)
sequence_id = serializers.IntegerField(
source="issue.sequence_id", read_only=True
)
name = serializers.CharField(source="issue.name", read_only=True)
relation_type = serializers.CharField(read_only=True)
class Meta:
model = IssueRelation
fields = [
"issue_detail",
"id",
"project_id",
"sequence_id",
"relation_type",
"related_issue",
"issue",
"id"
"name",
]
read_only_fields = [
"workspace",
@ -400,7 +435,8 @@ class IssueLinkSerializer(BaseSerializer):
# Validation if url already exists
def create(self, validated_data):
if IssueLink.objects.filter(
url=validated_data.get("url"), issue_id=validated_data.get("issue_id")
url=validated_data.get("url"),
issue_id=validated_data.get("issue_id"),
).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
@ -424,7 +460,6 @@ class IssueAttachmentSerializer(BaseSerializer):
class IssueReactionSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
@ -438,19 +473,6 @@ class IssueReactionSerializer(BaseSerializer):
]
class CommentReactionLiteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = CommentReaction
fields = [
"id",
"reaction",
"comment",
"actor_detail",
]
class CommentReactionSerializer(BaseSerializer):
class Meta:
model = CommentReaction
@ -459,12 +481,18 @@ class CommentReactionSerializer(BaseSerializer):
class IssueVoteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueVote
fields = ["issue", "vote", "workspace", "project", "actor", "actor_detail"]
fields = [
"issue",
"vote",
"workspace",
"project",
"actor",
"actor_detail",
]
read_only_fields = fields
@ -472,8 +500,12 @@ class IssueCommentSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True)
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
comment_reactions = CommentReactionSerializer(
read_only=True, many=True
)
is_member = serializers.BooleanField(read_only=True)
class Meta:
@ -507,12 +539,15 @@ class IssueStateFlatSerializer(BaseSerializer):
# Issue Serializer with state details
class IssueStateSerializer(DynamicBaseSerializer):
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
label_details = LabelLiteSerializer(
read_only=True, source="labels", many=True
)
state_detail = StateLiteSerializer(read_only=True, source="state")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
assignee_details = UserLiteSerializer(read_only=True, source="assignees", 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)
attachment_count = serializers.IntegerField(read_only=True)
link_count = serializers.IntegerField(read_only=True)
@ -521,40 +556,80 @@ class IssueStateSerializer(DynamicBaseSerializer):
fields = "__all__"
class IssueSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateSerializer(read_only=True, source="state")
parent_detail = IssueStateFlatSerializer(read_only=True, source="parent")
label_details = LabelSerializer(read_only=True, source="labels", many=True)
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True)
issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", many=True)
issue_cycle = IssueCycleDetailSerializer(read_only=True)
issue_module = IssueModuleDetailSerializer(read_only=True)
issue_link = IssueLinkSerializer(read_only=True, many=True)
issue_attachment = IssueAttachmentSerializer(read_only=True, many=True)
class IssueSerializer(DynamicBaseSerializer):
# ids
project_id = serializers.PrimaryKeyRelatedField(read_only=True)
state_id = serializers.PrimaryKeyRelatedField(read_only=True)
parent_id = serializers.PrimaryKeyRelatedField(read_only=True)
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
module_ids = serializers.SerializerMethodField()
# Many to many
label_ids = serializers.PrimaryKeyRelatedField(
read_only=True, many=True, source="labels"
)
assignee_ids = serializers.PrimaryKeyRelatedField(
read_only=True, many=True, source="assignees"
)
# Count items
sub_issues_count = serializers.IntegerField(read_only=True)
issue_reactions = IssueReactionSerializer(read_only=True, many=True)
attachment_count = serializers.IntegerField(read_only=True)
link_count = serializers.IntegerField(read_only=True)
# is_subscribed
is_subscribed = serializers.BooleanField(read_only=True)
class Meta:
model = Issue
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"created_by",
"updated_by",
fields = [
"id",
"name",
"state_id",
"description_html",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_subscribed",
"is_draft",
"archived_at",
]
read_only_fields = fields
def get_module_ids(self, obj):
# Access the prefetched modules and extract module IDs
return [module for module in obj.issue_module.values_list("module_id", flat=True)]
class IssueLiteSerializer(DynamicBaseSerializer):
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
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)
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)
@ -581,7 +656,9 @@ class IssueLiteSerializer(DynamicBaseSerializer):
class IssuePublicSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateLiteSerializer(read_only=True, source="state")
reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions")
reactions = IssueReactionSerializer(
read_only=True, many=True, source="issue_reactions"
)
votes = IssueVoteSerializer(read_only=True, many=True)
class Meta:
@ -604,7 +681,6 @@ class IssuePublicSerializer(BaseSerializer):
read_only_fields = fields
class IssueSubscriberSerializer(BaseSerializer):
class Meta:
model = IssueSubscriber

View File

@ -2,7 +2,7 @@
from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .base import BaseSerializer, DynamicBaseSerializer
from .user import UserLiteSerializer
from .project import ProjectLiteSerializer
from .workspace import WorkspaceLiteSerializer
@ -14,6 +14,7 @@ from plane.db.models import (
ModuleIssue,
ModuleLink,
ModuleFavorite,
ModuleUserProperties,
)
@ -25,7 +26,9 @@ class ModuleWriteSerializer(BaseSerializer):
)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
workspace_detail = WorkspaceLiteSerializer(
source="workspace", read_only=True
)
class Meta:
model = Module
@ -41,12 +44,18 @@ class ModuleWriteSerializer(BaseSerializer):
def to_representation(self, instance):
data = super().to_representation(instance)
data['members'] = [str(member.id) for member in instance.members.all()]
data["members"] = [str(member.id) for member in instance.members.all()]
return data
def validate(self, data):
if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None):
raise serializers.ValidationError("Start date cannot exceed target date")
if (
data.get("start_date", None) is not None
and data.get("target_date", None) is not None
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError(
"Start date cannot exceed target date"
)
return data
def create(self, validated_data):
@ -151,7 +160,8 @@ class ModuleLinkSerializer(BaseSerializer):
# Validation if url already exists
def create(self, validated_data):
if ModuleLink.objects.filter(
url=validated_data.get("url"), module_id=validated_data.get("module_id")
url=validated_data.get("url"),
module_id=validated_data.get("module_id"),
).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
@ -159,10 +169,12 @@ class ModuleLinkSerializer(BaseSerializer):
return ModuleLink.objects.create(**validated_data)
class ModuleSerializer(BaseSerializer):
class ModuleSerializer(DynamicBaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
lead_detail = UserLiteSerializer(read_only=True, source="lead")
members_detail = UserLiteSerializer(read_only=True, many=True, source="members")
members_detail = UserLiteSerializer(
read_only=True, many=True, source="members"
)
link_module = ModuleLinkSerializer(read_only=True, many=True)
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
@ -196,3 +208,10 @@ class ModuleFavoriteSerializer(BaseSerializer):
"project",
"user",
]
class ModuleUserPropertiesSerializer(BaseSerializer):
class Meta:
model = ModuleUserProperties
fields = "__all__"
read_only_fields = ["workspace", "project", "module", "user"]

View File

@ -1,12 +1,21 @@
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from plane.db.models import Notification
from plane.db.models import Notification, UserNotificationPreference
class NotificationSerializer(BaseSerializer):
triggered_by_details = UserLiteSerializer(read_only=True, source="triggered_by")
triggered_by_details = UserLiteSerializer(
read_only=True, source="triggered_by"
)
class Meta:
model = Notification
fields = "__all__"
class UserNotificationPreferenceSerializer(BaseSerializer):
class Meta:
model = UserNotificationPreference
fields = "__all__"

View File

@ -6,19 +6,31 @@ from .base import BaseSerializer
from .issue import IssueFlatSerializer, LabelLiteSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import Page, PageLog, PageFavorite, PageLabel, Label, Issue, Module
from plane.db.models import (
Page,
PageLog,
PageFavorite,
PageLabel,
Label,
Issue,
Module,
)
class PageSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
label_details = LabelLiteSerializer(
read_only=True, source="labels", many=True
)
labels = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True,
required=False,
)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
workspace_detail = WorkspaceLiteSerializer(
source="workspace", read_only=True
)
class Meta:
model = Page
@ -28,9 +40,10 @@ class PageSerializer(BaseSerializer):
"project",
"owned_by",
]
def to_representation(self, instance):
data = super().to_representation(instance)
data['labels'] = [str(label.id) for label in instance.labels.all()]
data["labels"] = [str(label.id) for label in instance.labels.all()]
return data
def create(self, validated_data):
@ -94,7 +107,7 @@ class SubPageSerializer(BaseSerializer):
def get_entity_details(self, obj):
entity_name = obj.entity_name
if entity_name == 'forward_link' or entity_name == 'back_link':
if entity_name == "forward_link" or entity_name == "back_link":
try:
page = Page.objects.get(pk=obj.entity_identifier)
return PageSerializer(page).data
@ -104,7 +117,6 @@ class SubPageSerializer(BaseSerializer):
class PageLogSerializer(BaseSerializer):
class Meta:
model = PageLog
fields = "__all__"

View File

@ -4,7 +4,10 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
from plane.app.serializers.workspace import WorkspaceLiteSerializer
from plane.app.serializers.user import UserLiteSerializer, UserAdminLiteSerializer
from plane.app.serializers.user import (
UserLiteSerializer,
UserAdminLiteSerializer,
)
from plane.db.models import (
Project,
ProjectMember,
@ -17,7 +20,9 @@ from plane.db.models import (
class ProjectSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
workspace_detail = WorkspaceLiteSerializer(
source="workspace", read_only=True
)
class Meta:
model = Project
@ -29,12 +34,16 @@ class ProjectSerializer(BaseSerializer):
def create(self, validated_data):
identifier = validated_data.get("identifier", "").strip().upper()
if identifier == "":
raise serializers.ValidationError(detail="Project Identifier is required")
raise serializers.ValidationError(
detail="Project Identifier is required"
)
if ProjectIdentifier.objects.filter(
name=identifier, workspace_id=self.context["workspace_id"]
).exists():
raise serializers.ValidationError(detail="Project Identifier is taken")
raise serializers.ValidationError(
detail="Project Identifier is taken"
)
project = Project.objects.create(
**validated_data, workspace_id=self.context["workspace_id"]
)
@ -73,7 +82,9 @@ class ProjectSerializer(BaseSerializer):
return project
# If not same fail update
raise serializers.ValidationError(detail="Project Identifier is already taken")
raise serializers.ValidationError(
detail="Project Identifier is already taken"
)
class ProjectLiteSerializer(BaseSerializer):
@ -160,6 +171,12 @@ class ProjectMemberAdminSerializer(BaseSerializer):
fields = "__all__"
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
class Meta:
model = ProjectMember
fields = ("id", "role", "member", "project")
class ProjectMemberInviteSerializer(BaseSerializer):
project = ProjectLiteSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
@ -197,7 +214,9 @@ class ProjectMemberLiteSerializer(BaseSerializer):
class ProjectDeployBoardSerializer(BaseSerializer):
project_details = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
class Meta:
model = ProjectDeployBoard

View File

@ -6,10 +6,19 @@ from plane.db.models import State
class StateSerializer(BaseSerializer):
class Meta:
model = State
fields = "__all__"
fields = [
"id",
"project_id",
"workspace_id",
"name",
"color",
"group",
"default",
"description",
"sequence",
]
read_only_fields = [
"workspace",
"project",

View File

@ -99,7 +99,9 @@ class UserMeSettingsSerializer(BaseSerializer):
).first()
return {
"last_workspace_id": obj.last_workspace_id,
"last_workspace_slug": workspace.slug if workspace is not None else "",
"last_workspace_slug": workspace.slug
if workspace is not None
else "",
"fallback_workspace_id": obj.last_workspace_id,
"fallback_workspace_slug": workspace.slug
if workspace is not None
@ -109,7 +111,8 @@ class UserMeSettingsSerializer(BaseSerializer):
else:
fallback_workspace = (
Workspace.objects.filter(
workspace_member__member_id=obj.id, workspace_member__is_active=True
workspace_member__member_id=obj.id,
workspace_member__is_active=True,
)
.order_by("created_at")
.first()
@ -180,7 +183,9 @@ class ChangePasswordSerializer(serializers.Serializer):
if data.get("new_password") != data.get("confirm_password"):
raise serializers.ValidationError(
{"error": "Confirm password should be same as the new password."}
{
"error": "Confirm password should be same as the new password."
}
)
return data
@ -190,4 +195,5 @@ class ResetPasswordSerializer(serializers.Serializer):
"""
Serializer for password change endpoint.
"""
new_password = serializers.CharField(required=True, min_length=8)

View File

@ -2,7 +2,7 @@
from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .base import BaseSerializer, DynamicBaseSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import GlobalView, IssueView, IssueViewFavorite
@ -10,7 +10,9 @@ from plane.utils.issue_filters import issue_filters
class GlobalViewSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
workspace_detail = WorkspaceLiteSerializer(
source="workspace", read_only=True
)
class Meta:
model = GlobalView
@ -38,10 +40,12 @@ class GlobalViewSerializer(BaseSerializer):
return super().update(instance, validated_data)
class IssueViewSerializer(BaseSerializer):
class IssueViewSerializer(DynamicBaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
workspace_detail = WorkspaceLiteSerializer(
source="workspace", read_only=True
)
class Meta:
model = IssueView

View File

@ -12,6 +12,7 @@ from .base import DynamicBaseSerializer
from plane.db.models import Webhook, WebhookLog
from plane.db.models.webhook import validate_domain, validate_schema
class WebhookSerializer(DynamicBaseSerializer):
url = serializers.URLField(validators=[validate_schema, validate_domain])
@ -21,32 +22,49 @@ class WebhookSerializer(DynamicBaseSerializer):
# Extract the hostname from the URL
hostname = urlparse(url).hostname
if not hostname:
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
raise serializers.ValidationError(
{"url": "Invalid URL: No hostname found."}
)
# Resolve the hostname to IP addresses
try:
ip_addresses = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
raise serializers.ValidationError(
{"url": "Hostname could not be resolved."}
)
if not ip_addresses:
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
raise serializers.ValidationError(
{"url": "No IP addresses found for the hostname."}
)
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback:
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
raise serializers.ValidationError(
{"url": "URL resolves to a blocked IP address."}
)
# Additional validation for multiple request domains and their subdomains
request = self.context.get('request')
disallowed_domains = ['plane.so',] # Add your disallowed domains here
request = self.context.get("request")
disallowed_domains = [
"plane.so",
] # Add your disallowed domains here
if request:
request_host = request.get_host().split(':')[0] # Remove port if present
request_host = request.get_host().split(":")[
0
] # Remove port if present
disallowed_domains.append(request_host)
# Check if hostname is a subdomain or exact match of any disallowed domain
if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains):
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
if any(
hostname == domain or hostname.endswith("." + domain)
for domain in disallowed_domains
):
raise serializers.ValidationError(
{"url": "URL domain or its subdomain is not allowed."}
)
return Webhook.objects.create(**validated_data)
@ -56,32 +74,49 @@ class WebhookSerializer(DynamicBaseSerializer):
# Extract the hostname from the URL
hostname = urlparse(url).hostname
if not hostname:
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
raise serializers.ValidationError(
{"url": "Invalid URL: No hostname found."}
)
# Resolve the hostname to IP addresses
try:
ip_addresses = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
raise serializers.ValidationError(
{"url": "Hostname could not be resolved."}
)
if not ip_addresses:
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
raise serializers.ValidationError(
{"url": "No IP addresses found for the hostname."}
)
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback:
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
raise serializers.ValidationError(
{"url": "URL resolves to a blocked IP address."}
)
# Additional validation for multiple request domains and their subdomains
request = self.context.get('request')
disallowed_domains = ['plane.so',] # Add your disallowed domains here
request = self.context.get("request")
disallowed_domains = [
"plane.so",
] # Add your disallowed domains here
if request:
request_host = request.get_host().split(':')[0] # Remove port if present
request_host = request.get_host().split(":")[
0
] # Remove port if present
disallowed_domains.append(request_host)
# Check if hostname is a subdomain or exact match of any disallowed domain
if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains):
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
if any(
hostname == domain or hostname.endswith("." + domain)
for domain in disallowed_domains
):
raise serializers.ValidationError(
{"url": "URL domain or its subdomain is not allowed."}
)
return super().update(instance, validated_data)
@ -95,12 +130,7 @@ class WebhookSerializer(DynamicBaseSerializer):
class WebhookLogSerializer(DynamicBaseSerializer):
class Meta:
model = WebhookLog
fields = "__all__"
read_only_fields = [
"workspace",
"webhook"
]
read_only_fields = ["workspace", "webhook"]

View File

@ -2,7 +2,7 @@
from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .base import BaseSerializer, DynamicBaseSerializer
from .user import UserLiteSerializer, UserAdminLiteSerializer
from plane.db.models import (
@ -13,10 +13,11 @@ from plane.db.models import (
TeamMember,
WorkspaceMemberInvite,
WorkspaceTheme,
WorkspaceUserProperties,
)
class WorkSpaceSerializer(BaseSerializer):
class WorkSpaceSerializer(DynamicBaseSerializer):
owner = UserLiteSerializer(read_only=True)
total_members = serializers.IntegerField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
@ -50,6 +51,7 @@ class WorkSpaceSerializer(BaseSerializer):
"owner",
]
class WorkspaceLiteSerializer(BaseSerializer):
class Meta:
model = Workspace
@ -61,8 +63,7 @@ class WorkspaceLiteSerializer(BaseSerializer):
read_only_fields = fields
class WorkSpaceMemberSerializer(BaseSerializer):
class WorkSpaceMemberSerializer(DynamicBaseSerializer):
member = UserLiteSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
@ -72,13 +73,12 @@ class WorkSpaceMemberSerializer(BaseSerializer):
class WorkspaceMemberMeSerializer(BaseSerializer):
class Meta:
model = WorkspaceMember
fields = "__all__"
class WorkspaceMemberAdminSerializer(BaseSerializer):
class WorkspaceMemberAdminSerializer(DynamicBaseSerializer):
member = UserAdminLiteSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
@ -108,7 +108,9 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
class TeamSerializer(BaseSerializer):
members_detail = UserLiteSerializer(read_only=True, source="members", many=True)
members_detail = UserLiteSerializer(
read_only=True, source="members", many=True
)
members = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
@ -145,7 +147,9 @@ class TeamSerializer(BaseSerializer):
members = validated_data.pop("members")
TeamMember.objects.filter(team=instance).delete()
team_members = [
TeamMember(member=member, team=instance, workspace=instance.workspace)
TeamMember(
member=member, team=instance, workspace=instance.workspace
)
for member in members
]
TeamMember.objects.bulk_create(team_members, batch_size=10)
@ -161,3 +165,13 @@ class WorkspaceThemeSerializer(BaseSerializer):
"workspace",
"actor",
]
class WorkspaceUserPropertiesSerializer(BaseSerializer):
class Meta:
model = WorkspaceUserProperties
fields = "__all__"
read_only_fields = [
"workspace",
"user",
]

View File

@ -3,6 +3,7 @@ from .asset import urlpatterns as asset_urls
from .authentication import urlpatterns as authentication_urls
from .config import urlpatterns as configuration_urls
from .cycle import urlpatterns as cycle_urls
from .dashboard import urlpatterns as dashboard_urls
from .estimate import urlpatterns as estimate_urls
from .external import urlpatterns as external_urls
from .importer import urlpatterns as importer_urls
@ -28,6 +29,7 @@ urlpatterns = [
*authentication_urls,
*configuration_urls,
*cycle_urls,
*dashboard_urls,
*estimate_urls,
*external_urls,
*importer_urls,

View File

@ -31,8 +31,14 @@ urlpatterns = [
path("sign-in/", SignInEndpoint.as_view(), name="sign-in"),
path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"),
# magic sign in
path("magic-generate/", MagicGenerateEndpoint.as_view(), name="magic-generate"),
path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"),
path(
"magic-generate/",
MagicGenerateEndpoint.as_view(),
name="magic-generate",
),
path(
"magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"
),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
# Password Manipulation
path(
@ -52,6 +58,8 @@ urlpatterns = [
),
# API Tokens
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),
path("api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-tokens"),
path(
"api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-tokens"
),
## End API Tokens
]

View File

@ -1,7 +1,7 @@
from django.urls import path
from plane.app.views import ConfigurationEndpoint
from plane.app.views import ConfigurationEndpoint, MobileConfigurationEndpoint
urlpatterns = [
path(
@ -9,4 +9,9 @@ urlpatterns = [
ConfigurationEndpoint.as_view(),
name="configuration",
),
path(
"mobile-configs/",
MobileConfigurationEndpoint.as_view(),
name="configuration",
),
]

View File

@ -7,6 +7,7 @@ from plane.app.views import (
CycleDateCheckEndpoint,
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
)
@ -44,7 +45,7 @@ urlpatterns = [
name="project-issue-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:issue_id>/",
CycleIssueViewSet.as_view(
{
"get": "retrieve",
@ -84,4 +85,9 @@ urlpatterns = [
TransferCycleIssueEndpoint.as_view(),
name="transfer-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/user-properties/",
CycleUserPropertiesEndpoint.as_view(),
name="cycle-user-filters",
),
]

View File

@ -0,0 +1,23 @@
from django.urls import path
from plane.app.views import DashboardEndpoint, WidgetsEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/dashboard/",
DashboardEndpoint.as_view(),
name="dashboard",
),
path(
"workspaces/<str:slug>/dashboard/<uuid:dashboard_id>/",
DashboardEndpoint.as_view(),
name="dashboard",
),
path(
"dashboard/<uuid:dashboard_id>/widgets/<uuid:widget_id>/",
WidgetsEndpoint.as_view(),
name="widgets",
),
]

View File

@ -40,7 +40,7 @@ urlpatterns = [
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:issue_id>/",
InboxIssueViewSet.as_view(
{
"get": "retrieve",

View File

@ -235,7 +235,7 @@ urlpatterns = [
## End Comment Reactions
## IssueProperty
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-display-properties/",
"workspaces/<str:slug>/projects/<uuid:project_id>/user-properties/",
IssueUserDisplayPropertyEndpoint.as_view(),
name="project-issue-display-properties",
),
@ -275,16 +275,17 @@ urlpatterns = [
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-relation/",
IssueRelationViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="issue-relation",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-relation/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/remove-relation/",
IssueRelationViewSet.as_view(
{
"delete": "destroy",
"post": "remove_relation",
}
),
name="issue-relation",

View File

@ -7,6 +7,7 @@ from plane.app.views import (
ModuleLinkViewSet,
ModuleFavoriteViewSet,
BulkImportModulesEndpoint,
ModuleUserPropertiesEndpoint,
)
@ -34,17 +35,26 @@ urlpatterns = [
name="project-modules",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/modules/",
ModuleIssueViewSet.as_view(
{
"post": "create_issue_modules",
}
),
name="issue-module",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/issues/",
ModuleIssueViewSet.as_view(
{
"post": "create_module_issues",
"get": "list",
"post": "create",
}
),
name="project-module-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/issues/<uuid:issue_id>/",
ModuleIssueViewSet.as_view(
{
"get": "retrieve",
@ -101,4 +111,9 @@ urlpatterns = [
BulkImportModulesEndpoint.as_view(),
name="bulk-modules-create",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/user-properties/",
ModuleUserPropertiesEndpoint.as_view(),
name="cycle-user-filters",
),
]

View File

@ -5,6 +5,7 @@ from plane.app.views import (
NotificationViewSet,
UnreadNotificationEndpoint,
MarkAllReadNotificationViewSet,
UserNotificationPreferenceEndpoint,
)
@ -63,4 +64,9 @@ urlpatterns = [
),
name="mark-all-read-notifications",
),
path(
"users/me/notification-preferences/",
UserNotificationPreferenceEndpoint.as_view(),
name="user-notification-preferences",
),
]

View File

@ -18,6 +18,10 @@ from plane.app.views import (
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceProjectMemberEndpoint,
WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint,
)
@ -92,6 +96,11 @@ urlpatterns = [
WorkSpaceMemberViewSet.as_view({"get": "list"}),
name="workspace-member",
),
path(
"workspaces/<str:slug>/project-members/",
WorkspaceProjectMemberEndpoint.as_view(),
name="workspace-member-roles",
),
path(
"workspaces/<str:slug>/members/<uuid:pk>/",
WorkSpaceMemberViewSet.as_view(
@ -195,4 +204,19 @@ urlpatterns = [
WorkspaceLabelsEndpoint.as_view(),
name="workspace-labels",
),
path(
"workspaces/<str:slug>/user-properties/",
WorkspaceUserPropertiesEndpoint.as_view(),
name="workspace-user-filters",
),
path(
"workspaces/<str:slug>/states/",
WorkspaceStatesEndpoint.as_view(),
name="workspace-state",
),
path(
"workspaces/<str:slug>/estimates/",
WorkspaceEstimatesEndpoint.as_view(),
name="workspace-estimate",
),
]

View File

@ -192,7 +192,7 @@ from plane.app.views import (
)
#TODO: Delete this file
# TODO: Delete this file
# This url file has been deprecated use apiserver/plane/urls folder to create new urls
urlpatterns = [
@ -204,10 +204,14 @@ urlpatterns = [
path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"),
# Magic Sign In/Up
path(
"magic-generate/", MagicSignInGenerateEndpoint.as_view(), name="magic-generate"
"magic-generate/",
MagicSignInGenerateEndpoint.as_view(),
name="magic-generate",
),
path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"),
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path(
"magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"
),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
# Email verification
path("email-verify/", VerifyEmailEndpoint.as_view(), name="email-verify"),
path(
@ -272,7 +276,9 @@ urlpatterns = [
# user workspace invitations
path(
"users/me/invitations/workspaces/",
UserWorkspaceInvitationsEndpoint.as_view({"get": "list", "post": "create"}),
UserWorkspaceInvitationsEndpoint.as_view(
{"get": "list", "post": "create"}
),
name="user-workspace-invitations",
),
# user workspace invitation
@ -311,7 +317,9 @@ urlpatterns = [
# user project invitations
path(
"users/me/invitations/projects/",
UserProjectInvitationsViewset.as_view({"get": "list", "post": "create"}),
UserProjectInvitationsViewset.as_view(
{"get": "list", "post": "create"}
),
name="user-project-invitaions",
),
## Workspaces ##
@ -1238,7 +1246,7 @@ urlpatterns = [
"post": "unarchive",
}
),
name="project-page-unarchive"
name="project-page-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-pages/",
@ -1264,19 +1272,22 @@ urlpatterns = [
{
"post": "unlock",
}
)
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/transactions/",
PageLogEndpoint.as_view(), name="page-transactions"
PageLogEndpoint.as_view(),
name="page-transactions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/transactions/<uuid:transaction>/",
PageLogEndpoint.as_view(), name="page-transactions"
PageLogEndpoint.as_view(),
name="page-transactions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/sub-pages/",
SubPagesEndpoint.as_view(), name="sub-page"
SubPagesEndpoint.as_view(),
name="sub-page",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/",
@ -1326,7 +1337,9 @@ urlpatterns = [
## End Pages
# API Tokens
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"),
path("api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-tokens"),
path(
"api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-tokens"
),
## End API Tokens
# Integrations
path(

View File

@ -45,6 +45,10 @@ from .workspace import (
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceProjectMemberEndpoint,
WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint,
)
from .state import StateViewSet
from .view import (
@ -59,6 +63,7 @@ from .cycle import (
CycleDateCheckEndpoint,
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
)
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
from .issue import (
@ -103,6 +108,7 @@ from .module import (
ModuleIssueViewSet,
ModuleLinkViewSet,
ModuleFavoriteViewSet,
ModuleUserPropertiesEndpoint,
)
from .api import ApiTokenEndpoint
@ -136,7 +142,11 @@ from .page import (
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint
from .external import (
GPTIntegrationEndpoint,
ReleaseNotesEndpoint,
UnsplashEndpoint,
)
from .estimate import (
ProjectEstimatePointEndpoint,
@ -157,14 +167,20 @@ from .notification import (
NotificationViewSet,
UnreadNotificationEndpoint,
MarkAllReadNotificationViewSet,
UserNotificationPreferenceEndpoint,
)
from .exporter import ExportIssuesEndpoint
from .config import ConfigurationEndpoint
from .config import ConfigurationEndpoint, MobileConfigurationEndpoint
from .webhook import (
WebhookEndpoint,
WebhookLogsEndpoint,
WebhookSecretRegenerateEndpoint,
)
from .dashboard import (
DashboardEndpoint,
WidgetsEndpoint
)

View File

@ -61,7 +61,9 @@ class AnalyticsEndpoint(BaseAPIView):
)
# If segment is present it cannot be same as x-axis
if segment and (segment not in valid_xaxis_segment or x_axis == segment):
if segment and (
segment not in valid_xaxis_segment or x_axis == segment
):
return Response(
{
"error": "Both segment and x axis cannot be same and segment should be valid"
@ -110,7 +112,9 @@ class AnalyticsEndpoint(BaseAPIView):
if x_axis in ["assignees__id"] or segment in ["assignees__id"]:
assignee_details = (
Issue.issue_objects.filter(
workspace__slug=slug, **filters, assignees__avatar__isnull=False
workspace__slug=slug,
**filters,
assignees__avatar__isnull=False,
)
.order_by("assignees__id")
.distinct("assignees__id")
@ -124,7 +128,9 @@ class AnalyticsEndpoint(BaseAPIView):
)
cycle_details = {}
if x_axis in ["issue_cycle__cycle_id"] or segment in ["issue_cycle__cycle_id"]:
if x_axis in ["issue_cycle__cycle_id"] or segment in [
"issue_cycle__cycle_id"
]:
cycle_details = (
Issue.issue_objects.filter(
workspace__slug=slug,
@ -186,7 +192,9 @@ class AnalyticViewViewset(BaseViewSet):
def get_queryset(self):
return self.filter_queryset(
super().get_queryset().filter(workspace__slug=self.kwargs.get("slug"))
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
)
@ -196,7 +204,9 @@ class SavedAnalyticEndpoint(BaseAPIView):
]
def get(self, request, slug, analytic_id):
analytic_view = AnalyticView.objects.get(pk=analytic_id, workspace__slug=slug)
analytic_view = AnalyticView.objects.get(
pk=analytic_id, workspace__slug=slug
)
filter = analytic_view.query
queryset = Issue.issue_objects.filter(**filter)
@ -266,7 +276,9 @@ class ExportAnalyticsEndpoint(BaseAPIView):
)
# If segment is present it cannot be same as x-axis
if segment and (segment not in valid_xaxis_segment or x_axis == segment):
if segment and (
segment not in valid_xaxis_segment or x_axis == segment
):
return Response(
{
"error": "Both segment and x axis cannot be same and segment should be valid"
@ -293,7 +305,9 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
def get(self, request, slug):
filters = issue_filters(request.GET, "GET")
base_issues = Issue.issue_objects.filter(workspace__slug=slug, **filters)
base_issues = Issue.issue_objects.filter(
workspace__slug=slug, **filters
)
total_issues = base_issues.count()
@ -306,7 +320,9 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
)
open_issues_groups = ["backlog", "unstarted", "started"]
open_issues_queryset = state_groups.filter(state__group__in=open_issues_groups)
open_issues_queryset = state_groups.filter(
state__group__in=open_issues_groups
)
open_issues = open_issues_queryset.count()
open_issues_classified = (
@ -361,10 +377,12 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
.order_by("-count")
)
open_estimate_sum = open_issues_queryset.aggregate(sum=Sum("estimate_point"))[
open_estimate_sum = open_issues_queryset.aggregate(
sum=Sum("estimate_point")
)["sum"]
total_estimate_sum = base_issues.aggregate(sum=Sum("estimate_point"))[
"sum"
]
total_estimate_sum = base_issues.aggregate(sum=Sum("estimate_point"))["sum"]
return Response(
{

View File

@ -71,7 +71,9 @@ class ApiTokenEndpoint(BaseAPIView):
user=request.user,
pk=pk,
)
serializer = APITokenSerializer(api_token, data=request.data, partial=True)
serializer = APITokenSerializer(
api_token, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@ -10,7 +10,11 @@ from plane.app.serializers import FileAssetSerializer
class FileAssetEndpoint(BaseAPIView):
parser_classes = (MultiPartParser, FormParser, JSONParser,)
parser_classes = (
MultiPartParser,
FormParser,
JSONParser,
)
"""
A viewset for viewing and editing task instances.
@ -20,10 +24,18 @@ class FileAssetEndpoint(BaseAPIView):
asset_key = str(workspace_id) + "/" + asset_key
files = FileAsset.objects.filter(asset=asset_key)
if files.exists():
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK)
serializer = FileAssetSerializer(
files, context={"request": request}, many=True
)
return Response(
{"data": serializer.data, "status": True},
status=status.HTTP_200_OK,
)
else:
return Response({"error": "Asset key does not exist", "status": False}, status=status.HTTP_200_OK)
return Response(
{"error": "Asset key does not exist", "status": False},
status=status.HTTP_200_OK,
)
def post(self, request, slug):
serializer = FileAssetSerializer(data=request.data)
@ -43,7 +55,6 @@ class FileAssetEndpoint(BaseAPIView):
class FileAssetViewSet(BaseViewSet):
def restore(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
file_asset = FileAsset.objects.get(asset=asset_key)
@ -56,12 +67,22 @@ class UserAssetsEndpoint(BaseAPIView):
parser_classes = (MultiPartParser, FormParser)
def get(self, request, asset_key):
files = FileAsset.objects.filter(asset=asset_key, created_by=request.user)
files = FileAsset.objects.filter(
asset=asset_key, created_by=request.user
)
if files.exists():
serializer = FileAssetSerializer(files, context={"request": request})
return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK)
serializer = FileAssetSerializer(
files, context={"request": request}
)
return Response(
{"data": serializer.data, "status": True},
status=status.HTTP_200_OK,
)
else:
return Response({"error": "Asset key does not exist", "status": False}, status=status.HTTP_200_OK)
return Response(
{"error": "Asset key does not exist", "status": False},
status=status.HTTP_200_OK,
)
def post(self, request):
serializer = FileAssetSerializer(data=request.data)
@ -70,9 +91,10 @@ class UserAssetsEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, asset_key):
file_asset = FileAsset.objects.get(asset=asset_key, created_by=request.user)
file_asset = FileAsset.objects.get(
asset=asset_key, created_by=request.user
)
file_asset.is_deleted = True
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -128,7 +128,8 @@ class ForgotPasswordEndpoint(BaseAPIView):
status=status.HTTP_200_OK,
)
return Response(
{"error": "Please check the email"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Please check the email"},
status=status.HTTP_400_BAD_REQUEST,
)
@ -167,7 +168,9 @@ class ResetPasswordEndpoint(BaseAPIView):
}
return Response(data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
except DjangoUnicodeDecodeError as indentifier:
return Response(
@ -191,7 +194,8 @@ class ChangePasswordEndpoint(BaseAPIView):
user.is_password_autoset = False
user.save()
return Response(
{"message": "Password updated successfully"}, status=status.HTTP_200_OK
{"message": "Password updated successfully"},
status=status.HTTP_200_OK,
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -213,7 +217,8 @@ class SetUserPasswordEndpoint(BaseAPIView):
# Check password validation
if not password and len(str(password)) < 8:
return Response(
{"error": "Password is not valid"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Password is not valid"},
status=status.HTTP_400_BAD_REQUEST,
)
# Set the user password
@ -281,7 +286,9 @@ class MagicGenerateEndpoint(BaseAPIView):
if data["current_attempt"] > 2:
return Response(
{"error": "Max attempts exhausted. Please try again later."},
{
"error": "Max attempts exhausted. Please try again later."
},
status=status.HTTP_400_BAD_REQUEST,
)
@ -339,7 +346,8 @@ class EmailCheckEndpoint(BaseAPIView):
if not email:
return Response(
{"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Email is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# validate the email
@ -347,7 +355,8 @@ class EmailCheckEndpoint(BaseAPIView):
validate_email(email)
except ValidationError:
return Response(
{"error": "Email is not valid"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Email is not valid"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the user exists
@ -399,13 +408,18 @@ class EmailCheckEndpoint(BaseAPIView):
key, token, current_attempt = generate_magic_token(email=email)
if not current_attempt:
return Response(
{"error": "Max attempts exhausted. Please try again later."},
{
"error": "Max attempts exhausted. Please try again later."
},
status=status.HTTP_400_BAD_REQUEST,
)
# Trigger the email
magic_link.delay(email, "magic_" + str(email), token, current_site)
return Response(
{"is_password_autoset": user.is_password_autoset, "is_existing": False},
{
"is_password_autoset": user.is_password_autoset,
"is_existing": False,
},
status=status.HTTP_200_OK,
)
@ -433,7 +447,9 @@ class EmailCheckEndpoint(BaseAPIView):
key, token, current_attempt = generate_magic_token(email=email)
if not current_attempt:
return Response(
{"error": "Max attempts exhausted. Please try again later."},
{
"error": "Max attempts exhausted. Please try again later."
},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -73,7 +73,7 @@ class SignUpEndpoint(BaseAPIView):
# get configuration values
# Get configuration values
ENABLE_SIGNUP, = get_configuration_value(
(ENABLE_SIGNUP,) = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
@ -173,7 +173,7 @@ class SignInEndpoint(BaseAPIView):
# Create the user
else:
ENABLE_SIGNUP, = get_configuration_value(
(ENABLE_SIGNUP,) = get_configuration_value(
[
{
"key": "ENABLE_SIGNUP",
@ -325,7 +325,7 @@ class MagicSignInEndpoint(BaseAPIView):
)
user_token = request.data.get("token", "").strip()
key = request.data.get("key", False).strip().lower()
key = request.data.get("key", "").strip().lower()
if not key or user_token == "":
return Response(
@ -364,8 +364,10 @@ class MagicSignInEndpoint(BaseAPIView):
user.save()
# Check if user has any accepted invites for workspace and add them to workspace
workspace_member_invites = WorkspaceMemberInvite.objects.filter(
email=user.email, accepted=True
workspace_member_invites = (
WorkspaceMemberInvite.objects.filter(
email=user.email, accepted=True
)
)
WorkspaceMember.objects.bulk_create(
@ -431,7 +433,9 @@ class MagicSignInEndpoint(BaseAPIView):
else:
return Response(
{"error": "Your login code was incorrect. Please try again."},
{
"error": "Your login code was incorrect. Please try again."
},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -46,7 +46,9 @@ class WebhookMixin:
bulk = False
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
response = super().finalize_response(
request, response, *args, **kwargs
)
# Check for the case should webhook be sent
if (
@ -88,7 +90,9 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
return self.model.objects.all()
except Exception as e:
capture_exception(e)
raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST)
raise APIException(
"Please check the view", status.HTTP_400_BAD_REQUEST
)
def handle_exception(self, exc):
"""
@ -99,6 +103,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
response = super().handle_exception(exc)
return response
except Exception as e:
print(e) if settings.DEBUG else print("Server Error")
if isinstance(e, IntegrityError):
return Response(
{"error": "The payload is not valid"},
@ -112,23 +117,23 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
)
if isinstance(e, ObjectDoesNotExist):
model_name = str(exc).split(" matching query does not exist.")[0]
return Response(
{"error": f"{model_name} does not exist."},
{"error": f"The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
if isinstance(e, KeyError):
capture_exception(e)
return Response(
{"error": f"key {e} does not exist"},
{"error": f"The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
print(e) if settings.DEBUG else print("Server Error")
capture_exception(e)
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
def dispatch(self, request, *args, **kwargs):
try:
@ -159,6 +164,24 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
if resolve(self.request.path_info).url_name == "project":
return self.kwargs.get("pk", None)
@property
def fields(self):
fields = [
field
for field in self.request.GET.get("fields", "").split(",")
if field
]
return fields if fields else None
@property
def expand(self):
expand = [
expand
for expand in self.request.GET.get("expand", "").split(",")
if expand
]
return expand if expand else None
class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
permission_classes = [
@ -201,20 +224,24 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
)
if isinstance(e, ObjectDoesNotExist):
model_name = str(exc).split(" matching query does not exist.")[0]
return Response(
{"error": f"{model_name} does not exist."},
{"error": f"The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
if isinstance(e, KeyError):
return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": f"The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
if settings.DEBUG:
print(e)
capture_exception(e)
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
def dispatch(self, request, *args, **kwargs):
try:
@ -239,3 +266,21 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
@property
def project_id(self):
return self.kwargs.get("project_id", None)
@property
def fields(self):
fields = [
field
for field in self.request.GET.get("fields", "").split(",")
if field
]
return fields if fields else None
@property
def expand(self):
expand = [
expand
for expand in self.request.GET.get("expand", "").split(",")
if expand
]
return expand if expand else None

View File

@ -20,7 +20,6 @@ class ConfigurationEndpoint(BaseAPIView):
]
def get(self, request):
# Get all the configuration
(
GOOGLE_CLIENT_ID,
@ -90,8 +89,16 @@ class ConfigurationEndpoint(BaseAPIView):
data = {}
# Authentication
data["google_client_id"] = GOOGLE_CLIENT_ID if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != "\"\"" else None
data["github_client_id"] = GITHUB_CLIENT_ID if GITHUB_CLIENT_ID and GITHUB_CLIENT_ID != "\"\"" else None
data["google_client_id"] = (
GOOGLE_CLIENT_ID
if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != '""'
else None
)
data["github_client_id"] = (
GITHUB_CLIENT_ID
if GITHUB_CLIENT_ID and GITHUB_CLIENT_ID != '""'
else None
)
data["github_app_name"] = GITHUB_APP_NAME
data["magic_login"] = (
bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD)
@ -112,9 +119,129 @@ class ConfigurationEndpoint(BaseAPIView):
data["has_openai_configured"] = bool(OPENAI_API_KEY)
# File size settings
data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880))
data["file_size_limit"] = float(
os.environ.get("FILE_SIZE_LIMIT", 5242880)
)
# is self managed
data["is_self_managed"] = bool(int(os.environ.get("IS_SELF_MANAGED", "1")))
# is smtp configured
data["is_smtp_configured"] = bool(EMAIL_HOST_USER) and bool(
EMAIL_HOST_PASSWORD
)
return Response(data, status=status.HTTP_200_OK)
class MobileConfigurationEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request):
(
GOOGLE_CLIENT_ID,
GOOGLE_SERVER_CLIENT_ID,
GOOGLE_IOS_CLIENT_ID,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
ENABLE_MAGIC_LINK_LOGIN,
ENABLE_EMAIL_PASSWORD,
POSTHOG_API_KEY,
POSTHOG_HOST,
UNSPLASH_ACCESS_KEY,
OPENAI_API_KEY,
) = get_configuration_value(
[
{
"key": "GOOGLE_CLIENT_ID",
"default": os.environ.get("GOOGLE_CLIENT_ID", None),
},
{
"key": "GOOGLE_SERVER_CLIENT_ID",
"default": os.environ.get("GOOGLE_SERVER_CLIENT_ID", None),
},
{
"key": "GOOGLE_IOS_CLIENT_ID",
"default": os.environ.get("GOOGLE_IOS_CLIENT_ID", None),
},
{
"key": "EMAIL_HOST_USER",
"default": os.environ.get("EMAIL_HOST_USER", None),
},
{
"key": "EMAIL_HOST_PASSWORD",
"default": os.environ.get("EMAIL_HOST_PASSWORD", None),
},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
"default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"),
},
{
"key": "ENABLE_EMAIL_PASSWORD",
"default": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
},
{
"key": "POSTHOG_API_KEY",
"default": os.environ.get("POSTHOG_API_KEY", "1"),
},
{
"key": "POSTHOG_HOST",
"default": os.environ.get("POSTHOG_HOST", "1"),
},
{
"key": "UNSPLASH_ACCESS_KEY",
"default": os.environ.get("UNSPLASH_ACCESS_KEY", "1"),
},
{
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", "1"),
},
]
)
data = {}
# Authentication
data["google_client_id"] = (
GOOGLE_CLIENT_ID
if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != '""'
else None
)
data["google_server_client_id"] = (
GOOGLE_SERVER_CLIENT_ID
if GOOGLE_SERVER_CLIENT_ID and GOOGLE_SERVER_CLIENT_ID != '""'
else None
)
data["google_ios_client_id"] = (
(GOOGLE_IOS_CLIENT_ID)[::-1]
if GOOGLE_IOS_CLIENT_ID is not None
else None
)
# Posthog
data["posthog_api_key"] = POSTHOG_API_KEY
data["posthog_host"] = POSTHOG_HOST
data["magic_login"] = (
bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD)
) and ENABLE_MAGIC_LINK_LOGIN == "1"
data["email_password_login"] = ENABLE_EMAIL_PASSWORD == "1"
# Posthog
data["posthog_api_key"] = POSTHOG_API_KEY
data["posthog_host"] = POSTHOG_HOST
# Unsplash
data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY)
# Open AI settings
data["has_openai_configured"] = bool(OPENAI_API_KEY)
# File size settings
data["file_size_limit"] = float(
os.environ.get("FILE_SIZE_LIMIT", 5242880)
)
# is smtp configured
data["is_smtp_configured"] = not (
bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD)
)
return Response(data, status=status.HTTP_200_OK)

View File

@ -14,7 +14,7 @@ from django.db.models import (
Case,
When,
Value,
CharField
CharField,
)
from django.core import serializers
from django.utils import timezone
@ -31,10 +31,15 @@ from plane.app.serializers import (
CycleSerializer,
CycleIssueSerializer,
CycleFavoriteSerializer,
IssueSerializer,
IssueStateSerializer,
CycleWriteSerializer,
CycleUserPropertiesSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
)
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
User,
Cycle,
@ -44,9 +49,10 @@ from plane.db.models import (
IssueLink,
IssueAttachment,
Label,
CycleUserProperties,
IssueSubscriber,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
from plane.utils.analytics_plot import burndown_plot
@ -61,7 +67,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"), owned_by=self.request.user
project_id=self.kwargs.get("project_id"),
owned_by=self.request.user,
)
def get_queryset(self):
@ -140,7 +147,9 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
),
)
)
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
.annotate(
total_estimates=Sum("issue_cycle__issue__estimate_point")
)
.annotate(
completed_estimates=Sum(
"issue_cycle__issue__estimate_point",
@ -164,20 +173,17 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
then=Value("CURRENT")
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(),
then=Value("UPCOMING")
),
When(
end_date__lt=timezone.now(),
then=Value("COMPLETED")
start_date__gt=timezone.now(), then=Value("UPCOMING")
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT")
then=Value("DRAFT"),
),
default=Value("DRAFT"),
output_field=CharField(),
@ -186,13 +192,17 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__labels",
queryset=Label.objects.only("name", "color", "id").distinct(),
queryset=Label.objects.only(
"name", "color", "id"
).distinct(),
)
)
.order_by("-is_favorite", "name")
@ -202,6 +212,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
def list(self, request, slug, project_id):
queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all")
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
queryset = queryset.order_by("-is_favorite", "-created_at")
@ -298,7 +313,9 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"completion_chart": {},
}
if data[0]["start_date"] and data[0]["end_date"]:
data[0]["distribution"]["completion_chart"] = burndown_plot(
data[0]["distribution"][
"completion_chart"
] = burndown_plot(
queryset=queryset.first(),
slug=slug,
project_id=project_id,
@ -307,44 +324,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
return Response(data, status=status.HTTP_200_OK)
# Upcoming Cycles
if cycle_view == "upcoming":
queryset = queryset.filter(start_date__gt=timezone.now())
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
# Completed Cycles
if cycle_view == "completed":
queryset = queryset.filter(end_date__lt=timezone.now())
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
# Draft Cycles
if cycle_view == "draft":
queryset = queryset.filter(
end_date=None,
start_date=None,
)
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
# Incomplete Cycles
if cycle_view == "incomplete":
queryset = queryset.filter(
Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True),
)
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
# If no matching view is found return all cycles
return Response(
CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
cycles = CycleSerializer(queryset, many=True).data
return Response(cycles, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
if (
@ -360,8 +341,18 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
project_id=project_id,
owned_by=request.user,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
cycle = (
self.get_queryset()
.filter(pk=serializer.data["id"])
.first()
)
serializer = CycleSerializer(cycle)
return Response(
serializer.data, status=status.HTTP_201_CREATED
)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
else:
return Response(
{
@ -371,15 +362,22 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
def partial_update(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
request_data = request.data
if cycle.end_date is not None and cycle.end_date < timezone.now().date():
if (
cycle.end_date is not None
and cycle.end_date < timezone.now().date()
):
if "sort_order" in request_data:
# Can only change sort order
request_data = {
"sort_order": request_data.get("sort_order", cycle.sort_order)
"sort_order": request_data.get(
"sort_order", cycle.sort_order
)
}
else:
return Response(
@ -389,7 +387,9 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleWriteSerializer(cycle, data=request.data, partial=True)
serializer = CycleWriteSerializer(
cycle, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
@ -410,7 +410,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(display_name=F("assignees__display_name"))
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
.values(
"first_name",
"last_name",
"assignee_id",
"avatar",
"display_name",
)
.annotate(
total_issues=Count(
"assignee_id",
@ -489,7 +495,10 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
if queryset.start_date and queryset.end_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=queryset, slug=slug, project_id=project_id, cycle_id=pk
queryset=queryset,
slug=slug,
project_id=project_id,
cycle_id=pk,
)
return Response(
@ -499,11 +508,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
def destroy(self, request, slug, project_id, pk):
cycle_issues = list(
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
"issue", flat=True
)
CycleIssue.objects.filter(
cycle_id=self.kwargs.get("pk")
).values_list("issue", flat=True)
)
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
issue_activity.delay(
type="cycle.activity.deleted",
@ -519,6 +530,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# Delete the cycle
cycle.delete()
@ -546,7 +559,9 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
super()
.get_queryset()
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id"))
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("issue_id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@ -565,28 +580,30 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug, project_id, cycle_id):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
order_by = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
sub_issues_count=Issue.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")
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.order_by(order_by)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@ -594,32 +611,43 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
subscriber=self.request.user, issue_id=OuterRef("id")
)
)
)
)
issues = IssueStateSerializer(
serializer = IssueSerializer(
issues, many=True, fields=fields if fields else None
).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(issue_dict, status=status.HTTP_200_OK)
)
return Response(serializer.data, status=status.HTTP_200_OK)
def create(self, request, slug, project_id, cycle_id):
issues = request.data.get("issues", [])
if not len(issues):
return Response(
{"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
if cycle.end_date is not None and cycle.end_date < timezone.now().date():
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"
@ -690,19 +718,27 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# Return all Cycle Issues
issues = self.get_queryset().values_list("issue_id", flat=True)
return Response(
CycleIssueSerializer(self.get_queryset(), many=True).data,
IssueSerializer(
Issue.objects.filter(pk__in=issues), many=True
).data,
status=status.HTTP_200_OK,
)
def destroy(self, request, slug, project_id, cycle_id, pk):
def destroy(self, request, slug, project_id, cycle_id, issue_id):
cycle_issue = CycleIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,
cycle_id=cycle_id,
)
issue_id = cycle_issue.issue_id
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
@ -712,10 +748,12 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
}
),
actor_id=str(self.request.user.id),
issue_id=str(cycle_issue.issue_id),
issue_id=str(issue_id),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
cycle_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -834,3 +872,41 @@ class TransferCycleIssueEndpoint(BaseAPIView):
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
class CycleUserPropertiesEndpoint(BaseAPIView):
permission_classes = [
ProjectLitePermission,
]
def patch(self, request, slug, project_id, cycle_id):
cycle_properties = CycleUserProperties.objects.get(
user=request.user,
cycle_id=cycle_id,
project_id=project_id,
workspace__slug=slug,
)
cycle_properties.filters = request.data.get(
"filters", cycle_properties.filters
)
cycle_properties.display_filters = request.data.get(
"display_filters", cycle_properties.display_filters
)
cycle_properties.display_properties = request.data.get(
"display_properties", cycle_properties.display_properties
)
cycle_properties.save()
serializer = CycleUserPropertiesSerializer(cycle_properties)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug, project_id, cycle_id):
cycle_properties, _ = CycleUserProperties.objects.get_or_create(
user=request.user,
project_id=project_id,
cycle_id=cycle_id,
workspace__slug=slug,
)
serializer = CycleUserPropertiesSerializer(cycle_properties)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@ -0,0 +1,656 @@
# Django imports
from django.db.models import (
Q,
Case,
When,
Value,
CharField,
Count,
F,
Exists,
OuterRef,
Max,
Subquery,
JSONField,
Func,
Prefetch,
)
from django.utils import timezone
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from . import BaseAPIView
from plane.db.models import (
Issue,
IssueActivity,
ProjectMember,
Widget,
DashboardWidget,
Dashboard,
Project,
IssueLink,
IssueAttachment,
IssueRelation,
)
from plane.app.serializers import (
IssueActivitySerializer,
IssueSerializer,
DashboardSerializer,
WidgetSerializer,
)
from plane.utils.issue_filters import issue_filters
def dashboard_overview_stats(self, request, slug):
assigned_issues = Issue.issue_objects.filter(
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
assignees__in=[request.user],
).count()
pending_issues_count = Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
assignees__in=[request.user],
).count()
created_issues_count = Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
created_by_id=request.user.id,
).count()
completed_issues_count = Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
assignees__in=[request.user],
state__group="completed",
).count()
return Response(
{
"assigned_issues_count": assigned_issues,
"pending_issues_count": pending_issues_count,
"completed_issues_count": completed_issues_count,
"created_issues_count": created_issues_count,
},
status=status.HTTP_200_OK,
)
def dashboard_assigned_issues(self, request, slug):
filters = issue_filters(request.query_params, "GET")
issue_type = request.GET.get("issue_type", None)
# get all the assigned issues
assigned_issues = (
Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
assignees__in=[request.user],
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_relation",
queryset=IssueRelation.objects.select_related(
"related_issue"
).select_related("issue"),
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.order_by("created_at")
)
# Priority Ordering
priority_order = ["urgent", "high", "medium", "low", "none"]
assigned_issues = assigned_issues.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
if issue_type == "completed":
completed_issues_count = assigned_issues.filter(
state__group__in=["completed"]
).count()
completed_issues = assigned_issues.filter(
state__group__in=["completed"]
)[:5]
return Response(
{
"issues": IssueSerializer(
completed_issues, many=True, expand=self.expand
).data,
"count": completed_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "overdue":
overdue_issues_count = assigned_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__lt=timezone.now()
).count()
overdue_issues = assigned_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__lt=timezone.now()
)[:5]
return Response(
{
"issues": IssueSerializer(
overdue_issues, many=True, expand=self.expand
).data,
"count": overdue_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "upcoming":
upcoming_issues_count = assigned_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__gte=timezone.now()
).count()
upcoming_issues = assigned_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__gte=timezone.now()
)[:5]
return Response(
{
"issues": IssueSerializer(
upcoming_issues, many=True, expand=self.expand
).data,
"count": upcoming_issues_count,
},
status=status.HTTP_200_OK,
)
return Response(
{"error": "Please specify a valid issue type"},
status=status.HTTP_400_BAD_REQUEST,
)
def dashboard_created_issues(self, request, slug):
filters = issue_filters(request.query_params, "GET")
issue_type = request.GET.get("issue_type", None)
# get all the assigned issues
created_issues = (
Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
created_by=request.user,
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.order_by("created_at")
)
# Priority Ordering
priority_order = ["urgent", "high", "medium", "low", "none"]
created_issues = created_issues.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
if issue_type == "completed":
completed_issues_count = created_issues.filter(
state__group__in=["completed"]
).count()
completed_issues = created_issues.filter(
state__group__in=["completed"]
)[:5]
return Response(
{
"issues": IssueSerializer(completed_issues, many=True).data,
"count": completed_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "overdue":
overdue_issues_count = created_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__lt=timezone.now()
).count()
overdue_issues = created_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__lt=timezone.now()
)[:5]
return Response(
{
"issues": IssueSerializer(overdue_issues, many=True).data,
"count": overdue_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "upcoming":
upcoming_issues_count = created_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__gte=timezone.now()
).count()
upcoming_issues = created_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__gte=timezone.now()
)[:5]
return Response(
{
"issues": IssueSerializer(upcoming_issues, many=True).data,
"count": upcoming_issues_count,
},
status=status.HTTP_200_OK,
)
return Response(
{"error": "Please specify a valid issue type"},
status=status.HTTP_400_BAD_REQUEST,
)
def dashboard_issues_by_state_groups(self, request, slug):
filters = issue_filters(request.query_params, "GET")
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
issues_by_state_groups = (
Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
assignees__in=[request.user],
)
.filter(**filters)
.values("state__group")
.annotate(count=Count("id"))
)
# default state
all_groups = {state: 0 for state in state_order}
# Update counts for existing groups
for entry in issues_by_state_groups:
all_groups[entry["state__group"]] = entry["count"]
# Prepare output including all groups with their counts
output_data = [
{"state": group, "count": count} for group, count in all_groups.items()
]
return Response(output_data, status=status.HTTP_200_OK)
def dashboard_issues_by_priority(self, request, slug):
filters = issue_filters(request.query_params, "GET")
priority_order = ["urgent", "high", "medium", "low", "none"]
issues_by_priority = (
Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
assignees__in=[request.user],
)
.filter(**filters)
.values("priority")
.annotate(count=Count("id"))
)
# default priority
all_groups = {priority: 0 for priority in priority_order}
# Update counts for existing groups
for entry in issues_by_priority:
all_groups[entry["priority"]] = entry["count"]
# Prepare output including all groups with their counts
output_data = [
{"priority": group, "count": count}
for group, count in all_groups.items()
]
return Response(output_data, status=status.HTTP_200_OK)
def dashboard_recent_activity(self, request, slug):
queryset = IssueActivity.objects.filter(
~Q(field__in=["comment", "vote", "reaction", "draft"]),
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
actor=request.user,
).select_related("actor", "workspace", "issue", "project")[:8]
return Response(
IssueActivitySerializer(queryset, many=True).data,
status=status.HTTP_200_OK,
)
def dashboard_recent_projects(self, request, slug):
project_ids = (
IssueActivity.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
actor=request.user,
)
.values_list("project_id", flat=True)
.distinct()
)
# Extract project IDs from the recent projects
unique_project_ids = set(project_id for project_id in project_ids)
# Fetch additional projects only if needed
if len(unique_project_ids) < 4:
additional_projects = Project.objects.filter(
project_projectmember__member=request.user,
project_projectmember__is_active=True,
workspace__slug=slug,
).exclude(id__in=unique_project_ids)
# Append additional project IDs to the existing list
unique_project_ids.update(additional_projects.values_list("id", flat=True))
return Response(
list(unique_project_ids)[:4],
status=status.HTTP_200_OK,
)
def dashboard_recent_collaborators(self, request, slug):
# Fetch all project IDs where the user belongs to
user_projects = Project.objects.filter(
project_projectmember__member=request.user,
project_projectmember__is_active=True,
workspace__slug=slug,
).values_list("id", flat=True)
# Fetch all users who have performed an activity in the projects where the user exists
users_with_activities = (
IssueActivity.objects.filter(
workspace__slug=slug,
project_id__in=user_projects,
)
.values("actor")
.exclude(actor=request.user)
.annotate(num_activities=Count("actor"))
.order_by("-num_activities")
)[:7]
# Get the count of active issues for each user in users_with_activities
users_with_active_issues = []
for user_activity in users_with_activities:
user_id = user_activity["actor"]
active_issue_count = Issue.objects.filter(
assignees__in=[user_id],
state__group__in=["unstarted", "started"],
).count()
users_with_active_issues.append(
{"user_id": user_id, "active_issue_count": active_issue_count}
)
# Insert the logged-in user's ID and their active issue count at the beginning
active_issue_count = Issue.objects.filter(
assignees__in=[request.user],
state__group__in=["unstarted", "started"],
).count()
if users_with_activities.count() < 7:
# Calculate the additional collaborators needed
additional_collaborators_needed = 7 - users_with_activities.count()
# Fetch additional collaborators from the project_member table
additional_collaborators = list(
set(
ProjectMember.objects.filter(
~Q(member=request.user),
project_id__in=user_projects,
workspace__slug=slug,
)
.exclude(
member__in=[
user["actor"] for user in users_with_activities
]
)
.values_list("member", flat=True)
)
)
additional_collaborators = additional_collaborators[
:additional_collaborators_needed
]
# Append additional collaborators to the list
for collaborator_id in additional_collaborators:
active_issue_count = Issue.objects.filter(
assignees__in=[collaborator_id],
state__group__in=["unstarted", "started"],
).count()
users_with_active_issues.append(
{
"user_id": str(collaborator_id),
"active_issue_count": active_issue_count,
}
)
users_with_active_issues.insert(
0,
{"user_id": request.user.id, "active_issue_count": active_issue_count},
)
return Response(users_with_active_issues, status=status.HTTP_200_OK)
class DashboardEndpoint(BaseAPIView):
def create(self, request, slug):
serializer = DashboardSerializer(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)
def patch(self, request, slug, pk):
serializer = DashboardSerializer(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)
def delete(self, request, slug, pk):
serializer = DashboardSerializer(data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get(self, request, slug, dashboard_id=None):
if not dashboard_id:
dashboard_type = request.GET.get("dashboard_type", None)
if dashboard_type == "home":
dashboard, created = Dashboard.objects.get_or_create(
type_identifier=dashboard_type, owned_by=request.user, is_default=True
)
if created:
widgets_to_fetch = [
"overview_stats",
"assigned_issues",
"created_issues",
"issues_by_state_groups",
"issues_by_priority",
"recent_activity",
"recent_projects",
"recent_collaborators",
]
updated_dashboard_widgets = []
for widget_key in widgets_to_fetch:
widget = Widget.objects.filter(key=widget_key).values_list("id", flat=True)
if widget:
updated_dashboard_widgets.append(
DashboardWidget(
widget_id=widget,
dashboard_id=dashboard.id,
)
)
DashboardWidget.objects.bulk_create(
updated_dashboard_widgets, batch_size=100
)
widgets = (
Widget.objects.annotate(
is_visible=Exists(
DashboardWidget.objects.filter(
widget_id=OuterRef("pk"),
dashboard_id=dashboard.id,
is_visible=True,
)
)
)
.annotate(
dashboard_filters=Subquery(
DashboardWidget.objects.filter(
widget_id=OuterRef("pk"),
dashboard_id=dashboard.id,
filters__isnull=False,
)
.exclude(filters={})
.values("filters")[:1]
)
)
.annotate(
widget_filters=Case(
When(
dashboard_filters__isnull=False,
then=F("dashboard_filters"),
),
default=F("filters"),
output_field=JSONField(),
)
)
)
return Response(
{
"dashboard": DashboardSerializer(dashboard).data,
"widgets": WidgetSerializer(widgets, many=True).data,
},
status=status.HTTP_200_OK,
)
return Response(
{"error": "Please specify a valid dashboard type"},
status=status.HTTP_400_BAD_REQUEST,
)
widget_key = request.GET.get("widget_key", "overview_stats")
WIDGETS_MAPPER = {
"overview_stats": dashboard_overview_stats,
"assigned_issues": dashboard_assigned_issues,
"created_issues": dashboard_created_issues,
"issues_by_state_groups": dashboard_issues_by_state_groups,
"issues_by_priority": dashboard_issues_by_priority,
"recent_activity": dashboard_recent_activity,
"recent_projects": dashboard_recent_projects,
"recent_collaborators": dashboard_recent_collaborators,
}
func = WIDGETS_MAPPER.get(widget_key)
if func is not None:
response = func(
self,
request=request,
slug=slug,
)
if isinstance(response, Response):
return response
return Response(
{"error": "Please specify a valid widget key"},
status=status.HTTP_400_BAD_REQUEST,
)
class WidgetsEndpoint(BaseAPIView):
def patch(self, request, dashboard_id, widget_id):
dashboard_widget = DashboardWidget.objects.filter(
widget_id=widget_id,
dashboard_id=dashboard_id,
).first()
dashboard_widget.is_visible = request.data.get(
"is_visible", dashboard_widget.is_visible
)
dashboard_widget.sort_order = request.data.get(
"sort_order", dashboard_widget.sort_order
)
dashboard_widget.filters = request.data.get(
"filters", dashboard_widget.filters
)
dashboard_widget.save()
return Response(
{"message": "successfully updated"}, status=status.HTTP_200_OK
)

View File

@ -19,16 +19,16 @@ class ProjectEstimatePointEndpoint(BaseAPIView):
]
def get(self, request, slug, project_id):
project = Project.objects.get(workspace__slug=slug, pk=project_id)
if project.estimate_id is not None:
estimate_points = EstimatePoint.objects.filter(
estimate_id=project.estimate_id,
project_id=project_id,
workspace__slug=slug,
)
serializer = EstimatePointSerializer(estimate_points, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response([], status=status.HTTP_200_OK)
project = Project.objects.get(workspace__slug=slug, pk=project_id)
if project.estimate_id is not None:
estimate_points = EstimatePoint.objects.filter(
estimate_id=project.estimate_id,
project_id=project_id,
workspace__slug=slug,
)
serializer = EstimatePointSerializer(estimate_points, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response([], status=status.HTTP_200_OK)
class BulkEstimatePointEndpoint(BaseViewSet):
@ -39,9 +39,13 @@ class BulkEstimatePointEndpoint(BaseViewSet):
serializer_class = EstimateSerializer
def list(self, request, slug, project_id):
estimates = Estimate.objects.filter(
workspace__slug=slug, project_id=project_id
).prefetch_related("points").select_related("workspace", "project")
estimates = (
Estimate.objects.filter(
workspace__slug=slug, project_id=project_id
)
.prefetch_related("points")
.select_related("workspace", "project")
)
serializer = EstimateReadSerializer(estimates, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@ -54,13 +58,17 @@ class BulkEstimatePointEndpoint(BaseViewSet):
estimate_points = request.data.get("estimate_points", [])
serializer = EstimatePointSerializer(data=request.data.get("estimate_points"), many=True)
serializer = EstimatePointSerializer(
data=request.data.get("estimate_points"), many=True
)
if not serializer.is_valid():
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
estimate_serializer = EstimateSerializer(data=request.data.get("estimate"))
estimate_serializer = EstimateSerializer(
data=request.data.get("estimate")
)
if not estimate_serializer.is_valid():
return Response(
estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST
@ -135,7 +143,8 @@ class BulkEstimatePointEndpoint(BaseViewSet):
estimate_points = EstimatePoint.objects.filter(
pk__in=[
estimate_point.get("id") for estimate_point in estimate_points_data
estimate_point.get("id")
for estimate_point in estimate_points_data
],
workspace__slug=slug,
project_id=project_id,
@ -157,10 +166,14 @@ class BulkEstimatePointEndpoint(BaseViewSet):
updated_estimate_points.append(estimate_point)
EstimatePoint.objects.bulk_update(
updated_estimate_points, ["value"], batch_size=10,
updated_estimate_points,
["value"],
batch_size=10,
)
estimate_point_serializer = EstimatePointSerializer(estimate_points, many=True)
estimate_point_serializer = EstimatePointSerializer(
estimate_points, many=True
)
return Response(
{
"estimate": estimate_serializer.data,

View File

@ -63,9 +63,11 @@ class ExportIssuesEndpoint(BaseAPIView):
def get(self, request, slug):
exporter_history = ExporterHistory.objects.filter(
workspace__slug=slug
).select_related("workspace","initiated_by")
).select_related("workspace", "initiated_by")
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
if request.GET.get("per_page", False) and request.GET.get(
"cursor", False
):
return self.paginate(
request=request,
queryset=exporter_history,

View File

@ -14,7 +14,10 @@ from django.conf import settings
from .base import BaseAPIView
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Workspace, Project
from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
from plane.app.serializers import (
ProjectLiteSerializer,
WorkspaceLiteSerializer,
)
from plane.utils.integrations.github import get_release_notes
from plane.license.utils.instance_value import get_configuration_value
@ -51,7 +54,8 @@ class GPTIntegrationEndpoint(BaseAPIView):
if not task:
return Response(
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Task is required"},
status=status.HTTP_400_BAD_REQUEST,
)
final_text = task + "\n" + prompt
@ -89,7 +93,7 @@ class ReleaseNotesEndpoint(BaseAPIView):
class UnsplashEndpoint(BaseAPIView):
def get(self, request):
UNSPLASH_ACCESS_KEY, = get_configuration_value(
(UNSPLASH_ACCESS_KEY,) = get_configuration_value(
[
{
"key": "UNSPLASH_ACCESS_KEY",

View File

@ -35,14 +35,16 @@ from plane.app.serializers import (
ModuleSerializer,
)
from plane.utils.integrations.github import get_github_repo_details
from plane.utils.importers.jira import jira_project_issue_summary
from plane.utils.importers.jira import (
jira_project_issue_summary,
is_allowed_hostname,
)
from plane.bgtasks.importer_task import service_importer
from plane.utils.html_processor import strip_tags
from plane.app.permissions import WorkSpaceAdminPermission
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
def get(self, request, slug, service):
if service == "github":
owner = request.GET.get("owner", False)
@ -94,7 +96,8 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
for key, error_message in params.items():
if not request.GET.get(key, False):
return Response(
{"error": error_message}, status=status.HTTP_400_BAD_REQUEST
{"error": error_message},
status=status.HTTP_400_BAD_REQUEST,
)
project_key = request.GET.get("project_key", "")
@ -122,6 +125,7 @@ class ImportServiceEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def post(self, request, slug, service):
project_id = request.data.get("project_id", False)
@ -174,6 +178,21 @@ class ImportServiceEndpoint(BaseAPIView):
data = request.data.get("data", False)
metadata = request.data.get("metadata", False)
config = request.data.get("config", False)
cloud_hostname = metadata.get("cloud_hostname", False)
if not cloud_hostname:
return Response(
{"error": "Cloud hostname is required"},
status=status.HTTP_400_BAD_REQUEST,
)
if not is_allowed_hostname(cloud_hostname):
return Response(
{"error": "Hostname is not a valid hostname."},
status=status.HTTP_400_BAD_REQUEST,
)
if not data or not metadata:
return Response(
{"error": "Data, config and metadata are required"},
@ -244,7 +263,9 @@ class ImportServiceEndpoint(BaseAPIView):
importer = Importer.objects.get(
pk=pk, service=service, workspace__slug=slug
)
serializer = ImporterSerializer(importer, data=request.data, partial=True)
serializer = ImporterSerializer(
importer, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
@ -280,9 +301,9 @@ class BulkImportIssuesEndpoint(BaseAPIView):
).first()
# Get the maximum sequence_id
last_id = IssueSequence.objects.filter(project_id=project_id).aggregate(
largest=Max("sequence")
)["largest"]
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
@ -315,7 +336,9 @@ class BulkImportIssuesEndpoint(BaseAPIView):
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", "<p></p>"),
description_html=issue_data.get(
"description_html", "<p></p>"
),
description_stripped=(
None
if (
@ -427,15 +450,21 @@ class BulkImportIssuesEndpoint(BaseAPIView):
for comment in comments_list
]
_ = IssueComment.objects.bulk_create(bulk_issue_comments, batch_size=100)
_ = 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"),
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,
@ -472,7 +501,9 @@ class BulkImportModulesEndpoint(BaseAPIView):
ignore_conflicts=True,
)
modules = Module.objects.filter(id__in=[module.id for module in modules])
modules = Module.objects.filter(
id__in=[module.id for module in modules]
)
if len(modules) == len(modules_data):
_ = ModuleLink.objects.bulk_create(
@ -520,6 +551,8 @@ class BulkImportModulesEndpoint(BaseAPIView):
else:
return Response(
{"message": "Modules created but issues could not be imported"},
{
"message": "Modules created but issues could not be imported"
},
status=status.HTTP_200_OK,
)

View File

@ -62,7 +62,9 @@ class InboxViewSet(BaseViewSet):
serializer.save(project_id=self.kwargs.get("project_id"))
def destroy(self, request, slug, project_id, pk):
inbox = Inbox.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
inbox = Inbox.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
# Handle default inbox delete
if inbox.is_default:
return Response(
@ -86,49 +88,14 @@ class InboxIssueViewSet(BaseViewSet):
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
inbox_id=self.kwargs.get("inbox_id"),
)
.select_related("issue", "workspace", "project")
)
def list(self, request, slug, project_id, inbox_id):
filters = issue_filters(request.query_params, "GET")
issues = (
return (
Issue.objects.filter(
issue_inbox__inbox_id=inbox_id,
workspace__slug=slug,
project_id=project_id,
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_inbox__inbox_id=self.kwargs.get("inbox_id")
)
.filter(**filters)
.annotate(bridge_id=F("issue_inbox__id"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_inbox",
@ -137,8 +104,35 @@ class InboxIssueViewSet(BaseViewSet):
),
)
)
)
issues_data = IssueStateInboxSerializer(issues, many=True).data
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
def list(self, request, slug, project_id, inbox_id):
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters).order_by("issue_inbox__snoozed_till", "issue_inbox__status")
issues_data = IssueSerializer(issue_queryset, expand=self.expand, many=True).data
return Response(
issues_data,
status=status.HTTP_200_OK,
@ -147,7 +141,8 @@ class InboxIssueViewSet(BaseViewSet):
def create(self, request, slug, project_id, inbox_id):
if not request.data.get("issue", {}).get("name", False):
return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Name is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check for valid priority
@ -159,7 +154,8 @@ class InboxIssueViewSet(BaseViewSet):
"none",
]:
return Response(
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Invalid priority"},
status=status.HTTP_400_BAD_REQUEST,
)
# Create or get state
@ -192,6 +188,8 @@ class InboxIssueViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# create an inbox issue
InboxIssue.objects.create(
@ -201,12 +199,16 @@ class InboxIssueViewSet(BaseViewSet):
source=request.data.get("source", "in-app"),
)
serializer = IssueStateInboxSerializer(issue)
issue = (self.get_queryset().filter(pk=issue.id).first())
serializer = IssueSerializer(issue ,expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, inbox_id, pk):
def partial_update(self, request, slug, project_id, inbox_id, issue_id):
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,
inbox_id=inbox_id,
)
# Get the project member
project_member = ProjectMember.objects.get(
@ -229,7 +231,9 @@ class InboxIssueViewSet(BaseViewSet):
if bool(issue_data):
issue = Issue.objects.get(
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
pk=inbox_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
)
# Only allow guests and viewers to edit name and description
if project_member.role <= 10:
@ -239,7 +243,9 @@ class InboxIssueViewSet(BaseViewSet):
"description_html": issue_data.get(
"description_html", issue.description_html
),
"description": issue_data.get("description", issue.description),
"description": issue_data.get(
"description", issue.description
),
}
issue_serializer = IssueCreateSerializer(
@ -262,6 +268,8 @@ class InboxIssueViewSet(BaseViewSet):
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue_serializer.save()
else:
@ -285,7 +293,9 @@ class InboxIssueViewSet(BaseViewSet):
project_id=project_id,
)
state = State.objects.filter(
group="cancelled", workspace__slug=slug, project_id=project_id
group="cancelled",
workspace__slug=slug,
project_id=project_id,
).first()
if state is not None:
issue.state = state
@ -303,32 +313,35 @@ class InboxIssueViewSet(BaseViewSet):
if issue.state.name == "Triage":
# Move to default state
state = State.objects.filter(
workspace__slug=slug, project_id=project_id, default=True
workspace__slug=slug,
project_id=project_id,
default=True,
).first()
if state is not None:
issue.state = state
issue.save()
issue = (self.get_queryset().filter(pk=issue_id).first())
serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
else:
issue = (self.get_queryset().filter(pk=issue_id).first())
serializer = IssueSerializer(issue ,expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, inbox_id, pk):
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
issue = Issue.objects.get(
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
)
serializer = IssueStateInboxSerializer(issue)
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
issue = self.get_queryset().filter(pk=issue_id).first()
serializer = IssueSerializer(issue, expand=self.expand,)
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, inbox_id, pk):
def destroy(self, request, slug, project_id, inbox_id, issue_id):
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,
inbox_id=inbox_id,
)
# Get the project member
project_member = ProjectMember.objects.get(
@ -350,9 +363,8 @@ class InboxIssueViewSet(BaseViewSet):
if inbox_issue.status in [-2, -1, 0, 2]:
# Delete the issue also
Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id
workspace__slug=slug, project_id=project_id, pk=issue_id
).delete()
inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -1,6 +1,7 @@
# Python improts
import uuid
import requests
# Django imports
from django.contrib.auth.hashers import make_password
@ -19,7 +20,10 @@ from plane.db.models import (
WorkspaceMember,
APIToken,
)
from plane.app.serializers import IntegrationSerializer, WorkspaceIntegrationSerializer
from plane.app.serializers import (
IntegrationSerializer,
WorkspaceIntegrationSerializer,
)
from plane.utils.integrations.github import (
get_github_metadata,
delete_github_installation,
@ -27,6 +31,7 @@ from plane.utils.integrations.github import (
from plane.app.permissions import WorkSpaceAdminPermission
from plane.utils.integrations.slack import slack_oauth
class IntegrationViewSet(BaseViewSet):
serializer_class = IntegrationSerializer
model = Integration
@ -101,7 +106,10 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
code = request.data.get("code", False)
if not code:
return Response({"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "Code is required"},
status=status.HTTP_400_BAD_REQUEST,
)
slack_response = slack_oauth(code=code)
@ -110,7 +118,9 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
team_id = metadata.get("team", {}).get("id", False)
if not metadata or not access_token or not team_id:
return Response(
{"error": "Slack could not be installed. Please try again later"},
{
"error": "Slack could not be installed. Please try again later"
},
status=status.HTTP_400_BAD_REQUEST,
)
config = {"team_id": team_id, "access_token": access_token}

View File

@ -21,7 +21,10 @@ from plane.app.serializers import (
GithubCommentSyncSerializer,
)
from plane.utils.integrations.github import get_github_repos
from plane.app.permissions import ProjectBasePermission, ProjectEntityPermission
from plane.app.permissions import (
ProjectBasePermission,
ProjectEntityPermission,
)
class GithubRepositoriesEndpoint(BaseAPIView):
@ -185,7 +188,6 @@ class BulkCreateGithubIssueSyncEndpoint(BaseAPIView):
class GithubCommentSyncViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]

View File

@ -8,9 +8,16 @@ from sentry_sdk import capture_exception
# Module imports
from plane.app.views import BaseViewSet, BaseAPIView
from plane.db.models import SlackProjectSync, WorkspaceIntegration, ProjectMember
from plane.db.models import (
SlackProjectSync,
WorkspaceIntegration,
ProjectMember,
)
from plane.app.serializers import SlackProjectSyncSerializer
from plane.app.permissions import ProjectBasePermission, ProjectEntityPermission
from plane.app.permissions import (
ProjectBasePermission,
ProjectEntityPermission,
)
from plane.utils.integrations.slack import slack_oauth
@ -38,7 +45,8 @@ class SlackProjectSyncViewSet(BaseViewSet):
if not code:
return Response(
{"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Code is required"},
status=status.HTTP_400_BAD_REQUEST,
)
slack_response = slack_oauth(code=code)
@ -54,7 +62,9 @@ class SlackProjectSyncViewSet(BaseViewSet):
access_token=slack_response.get("access_token"),
scopes=slack_response.get("scope"),
bot_user_id=slack_response.get("bot_user_id"),
webhook_url=slack_response.get("incoming_webhook", {}).get("url"),
webhook_url=slack_response.get("incoming_webhook", {}).get(
"url"
),
data=slack_response,
team_id=slack_response.get("team", {}).get("id"),
team_name=slack_response.get("team", {}).get("name"),
@ -62,7 +72,9 @@ class SlackProjectSyncViewSet(BaseViewSet):
project_id=project_id,
)
_ = ProjectMember.objects.get_or_create(
member=workspace_integration.actor, role=20, project_id=project_id
member=workspace_integration.actor,
role=20,
project_id=project_id,
)
serializer = SlackProjectSyncSerializer(slack_project_sync)
return Response(serializer.data, status=status.HTTP_200_OK)
@ -74,6 +86,8 @@ class SlackProjectSyncViewSet(BaseViewSet):
)
capture_exception(e)
return Response(
{"error": "Slack could not be installed. Please try again later"},
{
"error": "Slack could not be installed. Please try again later"
},
status=status.HTTP_400_BAD_REQUEST,
)

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,8 @@ 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
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework.response import Response
@ -20,9 +22,13 @@ from plane.app.serializers import (
ModuleIssueSerializer,
ModuleLinkSerializer,
ModuleFavoriteSerializer,
IssueStateSerializer,
IssueSerializer,
ModuleUserPropertiesSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
)
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
Module,
ModuleIssue,
@ -32,6 +38,8 @@ from plane.db.models import (
ModuleFavorite,
IssueLink,
IssueAttachment,
IssueSubscriber,
ModuleUserProperties,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
@ -54,7 +62,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
def get_queryset(self):
subquery = ModuleFavorite.objects.filter(
user=self.request.user,
module_id=OuterRef("pk"),
@ -74,7 +81,9 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
.prefetch_related(
Prefetch(
"link_module",
queryset=ModuleLink.objects.select_related("module", "created_by"),
queryset=ModuleLink.objects.select_related(
"module", "created_by"
),
)
)
.annotate(
@ -136,7 +145,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
),
)
)
.order_by("-is_favorite","-created_at")
.order_by("-is_favorite", "-created_at")
)
def create(self, request, slug, project_id):
@ -153,6 +162,18 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
modules = ModuleSerializer(
queryset, many=True, fields=fields if fields else None
).data
return Response(modules, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk):
queryset = self.get_queryset().get(pk=pk)
@ -167,7 +188,13 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
.annotate(assignee_id=F("assignees__id"))
.annotate(display_name=F("assignees__display_name"))
.annotate(avatar=F("assignees__avatar"))
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
.values(
"first_name",
"last_name",
"assignee_id",
"avatar",
"display_name",
)
.annotate(
total_issues=Count(
"assignee_id",
@ -251,7 +278,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
if queryset.start_date and queryset.target_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=queryset, slug=slug, project_id=project_id, module_id=pk
queryset=queryset,
slug=slug,
project_id=project_id,
module_id=pk,
)
return Response(
@ -260,25 +290,28 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
def destroy(self, request, slug, project_id, pk):
module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
module = Module.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
module_issues = list(
ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True)
)
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
{
"module_id": str(pk),
"module_name": str(module.name),
"issues": [str(issue_id) for issue_id in module_issues],
}
),
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
ModuleIssue.objects.filter(module_id=pk).values_list(
"issue", flat=True
)
)
_ = [
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps({"module_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(issue),
project_id=project_id,
current_instance=json.dumps({"module_name": str(module.name)}),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for issue in module_issues
]
module.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -289,7 +322,6 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
webhook_event = "module_issue"
bulk = True
filterset_fields = [
"issue__labels__id",
"issue__assignees__id",
@ -299,53 +331,18 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
ProjectEntityPermission,
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(module_id=self.kwargs.get("module_id"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
.select_related("module")
.select_related("issue", "issue__state", "issue__project")
.prefetch_related("issue__assignees", "issue__labels")
.prefetch_related("module__members")
.distinct()
)
@method_decorator(gzip_page)
def list(self, request, slug, project_id, module_id):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
order_by = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(issue_module__module_id=module_id)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
def get_queryset(self):
return (
Issue.objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_module__module_id=self.kwargs.get("module_id")
)
.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)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("labels", "assignees")
.prefetch_related('issue_module__module')
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@ -353,114 +350,144 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(issue_dict, status=status.HTTP_200_OK)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
def create(self, request, slug, project_id, module_id):
@method_decorator(gzip_page)
def list(self, request, slug, project_id, module_id):
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters)
serializer = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None
)
return Response(serializer.data, status=status.HTTP_200_OK)
# create multiple issues inside a module
def create_module_issues(self, request, slug, project_id, module_id):
issues = request.data.get("issues", [])
if not len(issues):
return Response(
{"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
)
module = Module.objects.get(
workspace__slug=slug, project_id=project_id, pk=module_id
)
module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues))
update_module_issue_activity = []
records_to_update = []
record_to_create = []
for issue in issues:
module_issue = [
module_issue
for module_issue in module_issues
if str(module_issue.issue_id) in issues
]
if len(module_issue):
if module_issue[0].module_id != module_id:
update_module_issue_activity.append(
{
"old_module_id": str(module_issue[0].module_id),
"new_module_id": str(module_id),
"issue_id": str(module_issue[0].issue_id),
}
)
module_issue[0].module_id = module_id
records_to_update.append(module_issue[0])
else:
record_to_create.append(
ModuleIssue(
module=module,
issue_id=issue,
project_id=project_id,
workspace=module.workspace,
created_by=request.user,
updated_by=request.user,
)
project = Project.objects.get(pk=project_id)
_ = ModuleIssue.objects.bulk_create(
[
ModuleIssue(
issue_id=str(issue),
module_id=module_id,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
ModuleIssue.objects.bulk_create(
record_to_create,
for issue in issues
],
batch_size=10,
ignore_conflicts=True,
)
# Bulk Update the activity
_ = [
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"module_id": str(module_id)}),
actor_id=str(request.user.id),
issue_id=str(issue),
project_id=project_id,
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for issue in issues
]
issues = (self.get_queryset().filter(pk__in=issues))
serializer = IssueSerializer(issues , many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
ModuleIssue.objects.bulk_update(
records_to_update,
["module"],
# create multiple module inside an issue
def create_issue_modules(self, request, slug, project_id, issue_id):
modules = request.data.get("modules", [])
if not len(modules):
return Response(
{"error": "Modules are required"},
status=status.HTTP_400_BAD_REQUEST,
)
project = Project.objects.get(pk=project_id)
_ = ModuleIssue.objects.bulk_create(
[
ModuleIssue(
issue_id=issue_id,
module_id=module,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for module in modules
],
batch_size=10,
ignore_conflicts=True,
)
# Bulk Update the activity
_ = [
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"module_id": module}),
actor_id=str(request.user.id),
issue_id=issue_id,
project_id=project_id,
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for module in modules
]
# Capture Issue Activity
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"modules_list": issues}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_module_issues": update_module_issue_activity,
"created_module_issues": serializers.serialize(
"json", record_to_create
),
}
),
epoch=int(timezone.now().timestamp()),
)
issue = (self.get_queryset().filter(pk=issue_id).first())
serializer = IssueSerializer(issue)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
ModuleIssueSerializer(self.get_queryset(), many=True).data,
status=status.HTTP_200_OK,
)
def destroy(self, request, slug, project_id, module_id, pk):
def destroy(self, request, slug, project_id, module_id, issue_id):
module_issue = ModuleIssue.objects.get(
workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk
workspace__slug=slug,
project_id=project_id,
module_id=module_id,
issue_id=issue_id,
)
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
{
"module_id": str(module_id),
"issues": [str(module_issue.issue_id)],
}
),
requested_data=json.dumps({"module_id": str(module_id)}),
actor_id=str(request.user.id),
issue_id=str(module_issue.issue_id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
current_instance=json.dumps({"module_name": module_issue.module.name}),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
module_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -522,3 +549,41 @@ class ModuleFavoriteViewSet(BaseViewSet):
)
module_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleUserPropertiesEndpoint(BaseAPIView):
permission_classes = [
ProjectLitePermission,
]
def patch(self, request, slug, project_id, module_id):
module_properties = ModuleUserProperties.objects.get(
user=request.user,
module_id=module_id,
project_id=project_id,
workspace__slug=slug,
)
module_properties.filters = request.data.get(
"filters", module_properties.filters
)
module_properties.display_filters = request.data.get(
"display_filters", module_properties.display_filters
)
module_properties.display_properties = request.data.get(
"display_properties", module_properties.display_properties
)
module_properties.save()
serializer = ModuleUserPropertiesSerializer(module_properties)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug, project_id, module_id):
module_properties, _ = ModuleUserProperties.objects.get_or_create(
user=request.user,
project_id=project_id,
module_id=module_id,
workspace__slug=slug,
)
serializer = ModuleUserPropertiesSerializer(module_properties)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@ -1,5 +1,5 @@
# Django imports
from django.db.models import Q
from django.db.models import Q, OuterRef, Exists
from django.utils import timezone
# Third party imports
@ -15,8 +15,9 @@ from plane.db.models import (
IssueSubscriber,
Issue,
WorkspaceMember,
UserNotificationPreference,
)
from plane.app.serializers import NotificationSerializer
from plane.app.serializers import NotificationSerializer, UserNotificationPreferenceSerializer
class NotificationViewSet(BaseViewSet, BasePaginator):
@ -51,8 +52,10 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
# Filters based on query parameters
snoozed_filters = {
"true": Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False),
"false": Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
"true": Q(snoozed_till__lt=timezone.now())
| Q(snoozed_till__isnull=False),
"false": Q(snoozed_till__gte=timezone.now())
| Q(snoozed_till__isnull=True),
}
notifications = notifications.filter(snoozed_filters[snoozed])
@ -69,17 +72,39 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
# Subscribed issues
if type == "watching":
issue_ids = IssueSubscriber.objects.filter(
workspace__slug=slug, subscriber_id=request.user.id
).values_list("issue_id", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
issue_ids = (
IssueSubscriber.objects.filter(
workspace__slug=slug, subscriber_id=request.user.id
)
.annotate(
created=Exists(
Issue.objects.filter(
created_by=request.user, pk=OuterRef("issue_id")
)
)
)
.annotate(
assigned=Exists(
IssueAssignee.objects.filter(
pk=OuterRef("issue_id"), assignee=request.user
)
)
)
.filter(created=False, assigned=False)
.values_list("issue_id", flat=True)
)
notifications = notifications.filter(
entity_identifier__in=issue_ids,
)
# Assigned Issues
if type == "assigned":
issue_ids = IssueAssignee.objects.filter(
workspace__slug=slug, assignee_id=request.user.id
).values_list("issue_id", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
notifications = notifications.filter(
entity_identifier__in=issue_ids
)
# Created issues
if type == "created":
@ -94,10 +119,14 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
issue_ids = Issue.objects.filter(
workspace__slug=slug, created_by=request.user
).values_list("pk", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
notifications = notifications.filter(
entity_identifier__in=issue_ids
)
# Pagination
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
if request.GET.get("per_page", False) and request.GET.get(
"cursor", False
):
return self.paginate(
request=request,
queryset=(notifications),
@ -227,11 +256,13 @@ class MarkAllReadNotificationViewSet(BaseViewSet):
# Filter for snoozed notifications
if snoozed:
notifications = notifications.filter(
Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False)
Q(snoozed_till__lt=timezone.now())
| Q(snoozed_till__isnull=False)
)
else:
notifications = notifications.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
Q(snoozed_till__gte=timezone.now())
| Q(snoozed_till__isnull=True),
)
# Filter for archived or unarchive
@ -245,14 +276,18 @@ class MarkAllReadNotificationViewSet(BaseViewSet):
issue_ids = IssueSubscriber.objects.filter(
workspace__slug=slug, subscriber_id=request.user.id
).values_list("issue_id", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
notifications = notifications.filter(
entity_identifier__in=issue_ids
)
# Assigned Issues
if type == "assigned":
issue_ids = IssueAssignee.objects.filter(
workspace__slug=slug, assignee_id=request.user.id
).values_list("issue_id", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
notifications = notifications.filter(
entity_identifier__in=issue_ids
)
# Created issues
if type == "created":
@ -267,7 +302,9 @@ class MarkAllReadNotificationViewSet(BaseViewSet):
issue_ids = Issue.objects.filter(
workspace__slug=slug, created_by=request.user
).values_list("pk", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
notifications = notifications.filter(
entity_identifier__in=issue_ids
)
updated_notifications = []
for notification in notifications:
@ -277,3 +314,31 @@ class MarkAllReadNotificationViewSet(BaseViewSet):
updated_notifications, ["read_at"], batch_size=100
)
return Response({"message": "Successful"}, status=status.HTTP_200_OK)
class UserNotificationPreferenceEndpoint(BaseAPIView):
model = UserNotificationPreference
serializer_class = UserNotificationPreferenceSerializer
# request the object
def get(self, request):
user_notification_preference = UserNotificationPreference.objects.get(
user=request.user
)
serializer = UserNotificationPreferenceSerializer(
user_notification_preference
)
return Response(serializer.data, status=status.HTTP_200_OK)
# update the object
def patch(self, request):
user_notification_preference = UserNotificationPreference.objects.get(
user=request.user
)
serializer = UserNotificationPreferenceSerializer(
user_notification_preference, 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)

View File

@ -1,5 +1,5 @@
# Python imports
from datetime import timedelta, date, datetime
from datetime import date, datetime, timedelta
# Django imports
from django.db import connection
@ -7,30 +7,19 @@ from django.db.models import Exists, OuterRef, Q
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 import status
from rest_framework.response import Response
# Module imports
from .base import BaseViewSet, BaseAPIView
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
Page,
PageFavorite,
Issue,
IssueAssignee,
IssueActivity,
PageLog,
ProjectMember,
)
from plane.app.serializers import (
PageSerializer,
PageFavoriteSerializer,
PageLogSerializer,
IssueLiteSerializer,
SubPageSerializer,
)
from plane.app.serializers import (IssueLiteSerializer, PageFavoriteSerializer,
PageLogSerializer, PageSerializer,
SubPageSerializer)
from plane.db.models import (Issue, IssueActivity, IssueAssignee, Page,
PageFavorite, PageLog, ProjectMember)
# Module imports
from .base import BaseAPIView, BaseViewSet
def unarchive_archive_page_and_descendants(page_id, archived_at):
@ -97,7 +86,9 @@ class PageViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, pk):
try:
page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
page = Page.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
if page.is_locked:
return Response(
@ -127,7 +118,9 @@ class PageViewSet(BaseViewSet):
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)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
except Page.DoesNotExist:
return Response(
{
@ -157,22 +150,26 @@ class PageViewSet(BaseViewSet):
def list(self, request, slug, project_id):
queryset = self.get_queryset().filter(archived_at__isnull=True)
return Response(
PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
pages = PageSerializer(queryset, many=True).data
return Response(pages, status=status.HTTP_200_OK)
def archive(self, request, slug, project_id, page_id):
page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
page = Page.objects.get(
pk=page_id, workspace__slug=slug, project_id=project_id
)
# only the owner and admin can archive the page
# only the owner or admin can archive the page
if (
ProjectMember.objects.filter(
project_id=project_id, member=request.user, is_active=True, role__gt=20
project_id=project_id,
member=request.user,
is_active=True,
role__lte=15,
).exists()
or request.user.id != page.owned_by_id
and request.user.id != page.owned_by_id
):
return Response(
{"error": "Only the owner and admin can archive the page"},
{"error": "Only the owner or admin can archive the page"},
status=status.HTTP_400_BAD_REQUEST,
)
@ -181,17 +178,22 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
def unarchive(self, request, slug, project_id, page_id):
page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
page = Page.objects.get(
pk=page_id, workspace__slug=slug, project_id=project_id
)
# only the owner and admin can un archive the page
# only the owner or admin can un archive the page
if (
ProjectMember.objects.filter(
project_id=project_id, member=request.user, is_active=True, role__gt=20
project_id=project_id,
member=request.user,
is_active=True,
role__lte=15,
).exists()
or request.user.id != page.owned_by_id
and request.user.id != page.owned_by_id
):
return Response(
{"error": "Only the owner and admin can un archive the page"},
{"error": "Only the owner or admin can un archive the page"},
status=status.HTTP_400_BAD_REQUEST,
)
@ -210,17 +212,21 @@ class PageViewSet(BaseViewSet):
workspace__slug=slug,
).filter(archived_at__isnull=False)
return Response(
PageSerializer(pages, many=True).data, status=status.HTTP_200_OK
)
pages = PageSerializer(pages, many=True).data
return Response(pages, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
page = Page.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
# only the owner and admin can delete the page
if (
ProjectMember.objects.filter(
project_id=project_id, member=request.user, is_active=True, role__gt=20
project_id=project_id,
member=request.user,
is_active=True,
role__gt=20,
).exists()
or request.user.id != page.owned_by_id
):

View File

@ -36,6 +36,7 @@ from plane.app.serializers import (
ProjectFavoriteSerializer,
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
ProjectMemberRoleSerializer,
)
from plane.app.permissions import (
@ -67,7 +68,7 @@ from plane.bgtasks.project_invitation_task import project_invitation
class ProjectViewSet(WebhookMixin, BaseViewSet):
serializer_class = ProjectSerializer
serializer_class = ProjectListSerializer
model = Project
webhook_event = "project"
@ -75,19 +76,20 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
ProjectBasePermission,
]
def get_serializer_class(self, *args, **kwargs):
if self.action in ["update", "partial_update"]:
return ProjectSerializer
return ProjectDetailSerializer
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(Q(project_projectmember__member=self.request.user) | Q(network=2))
.filter(
Q(project_projectmember__member=self.request.user)
| Q(network=2)
)
.select_related(
"workspace", "workspace__owner", "default_assignee", "project_lead"
"workspace",
"workspace__owner",
"default_assignee",
"project_lead",
)
.annotate(
is_favorite=Exists(
@ -159,7 +161,11 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
)
def list(self, request, slug):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
sort_order_query = ProjectMember.objects.filter(
member=request.user,
@ -172,7 +178,9 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
.annotate(sort_order=Subquery(sort_order_query))
.order_by("sort_order", "name")
)
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
if request.GET.get("per_page", False) and request.GET.get(
"cursor", False
):
return self.paginate(
request=request,
queryset=(projects),
@ -180,12 +188,10 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
projects, many=True
).data,
)
return Response(
ProjectListSerializer(
projects, many=True, fields=fields if fields else None
).data
)
projects = ProjectListSerializer(
projects, many=True, fields=fields if fields else None
).data
return Response(projects, status=status.HTTP_200_OK)
def create(self, request, slug):
try:
@ -199,7 +205,9 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
# Add the user as Administrator to the project
project_member = ProjectMember.objects.create(
project_id=serializer.data["id"], member=request.user, role=20
project_id=serializer.data["id"],
member=request.user,
role=20,
)
# Also create the issue property for the user
_ = IssueProperty.objects.create(
@ -272,9 +280,15 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
]
)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
project = (
self.get_queryset()
.filter(pk=serializer.data["id"])
.first()
)
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
serializer.data, status=status.HTTP_201_CREATED
)
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST,
@ -287,7 +301,8 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
)
except Workspace.DoesNotExist as e:
return Response(
{"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND
{"error": "Workspace does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
except serializers.ValidationError as e:
return Response(
@ -312,7 +327,9 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
serializer.save()
if serializer.data["inbox_view"]:
Inbox.objects.get_or_create(
name=f"{project.name} Inbox", project=project, is_default=True
name=f"{project.name} Inbox",
project=project,
is_default=True,
)
# Create the triage state in Backlog group
@ -324,10 +341,16 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
color="#ff7700",
)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
project = (
self.get_queryset()
.filter(pk=serializer.data["id"])
.first()
)
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
except IntegrityError as e:
if "already exists" in str(e):
@ -337,7 +360,8 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
)
except (Project.DoesNotExist, Workspace.DoesNotExist):
return Response(
{"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND
{"error": "Project does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
except serializers.ValidationError as e:
return Response(
@ -372,11 +396,14 @@ class ProjectInvitationsViewset(BaseViewSet):
# Check if email is provided
if not emails:
return Response(
{"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Emails are required"},
status=status.HTTP_400_BAD_REQUEST,
)
requesting_user = ProjectMember.objects.get(
workspace__slug=slug, project_id=project_id, member_id=request.user.id
workspace__slug=slug,
project_id=project_id,
member_id=request.user.id,
)
# Check if any invited user has an higher role
@ -550,7 +577,9 @@ class ProjectJoinEndpoint(BaseAPIView):
_ = WorkspaceMember.objects.create(
workspace_id=project_invite.workspace_id,
member=user,
role=15 if project_invite.role >= 15 else project_invite.role,
role=15
if project_invite.role >= 15
else project_invite.role,
)
else:
# Else make him active
@ -656,11 +685,25 @@ class ProjectMemberViewSet(BaseViewSet):
.order_by("sort_order")
)
bulk_project_members = []
member_roles = {member.get("member_id"): member.get("role") for member in members}
# Update roles in the members array based on the member_roles dictionary
for project_member in ProjectMember.objects.filter(project_id=project_id, member_id__in=[member.get("member_id") for member in members]):
project_member.role = member_roles[str(project_member.member_id)]
project_member.is_active = True
bulk_project_members.append(project_member)
# Update the roles of the existing members
ProjectMember.objects.bulk_update(
bulk_project_members, ["is_active", "role"], batch_size=100
)
for member in members:
sort_order = [
project_member.get("sort_order")
for project_member in project_members
if str(project_member.get("member_id")) == str(member.get("member_id"))
if str(project_member.get("member_id"))
== str(member.get("member_id"))
]
bulk_project_members.append(
ProjectMember(
@ -668,7 +711,9 @@ class ProjectMemberViewSet(BaseViewSet):
role=member.get("role", 10),
project_id=project_id,
workspace_id=project.workspace_id,
sort_order=sort_order[0] - 10000 if len(sort_order) else 65535,
sort_order=sort_order[0] - 10000
if len(sort_order)
else 65535,
)
)
bulk_issue_props.append(
@ -679,25 +724,6 @@ class ProjectMemberViewSet(BaseViewSet):
)
)
# Check if the user is already a member of the project and is inactive
if ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member_id=member.get("member_id"),
is_active=False,
).exists():
member_detail = ProjectMember.objects.get(
workspace__slug=slug,
project_id=project_id,
member_id=member.get("member_id"),
is_active=False,
)
# Check if the user has not deactivated the account
user = User.objects.filter(pk=member.get("member_id")).first()
if user.is_active:
member_detail.is_active = True
member_detail.save(update_fields=["is_active"])
project_members = ProjectMember.objects.bulk_create(
bulk_project_members,
batch_size=10,
@ -708,18 +734,12 @@ class ProjectMemberViewSet(BaseViewSet):
bulk_issue_props, batch_size=10, ignore_conflicts=True
)
serializer = ProjectMemberSerializer(project_members, many=True)
project_members = ProjectMember.objects.filter(project_id=project_id, member_id__in=[member.get("member_id") for member in members])
serializer = ProjectMemberRoleSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def list(self, request, slug, project_id):
project_member = ProjectMember.objects.get(
member=request.user,
workspace__slug=slug,
project_id=project_id,
is_active=True,
)
# Get the list of project members for the project
project_members = ProjectMember.objects.filter(
project_id=project_id,
workspace__slug=slug,
@ -727,10 +747,9 @@ class ProjectMemberViewSet(BaseViewSet):
is_active=True,
).select_related("project", "member", "workspace")
if project_member.role > 10:
serializer = ProjectMemberAdminSerializer(project_members, many=True)
else:
serializer = ProjectMemberSerializer(project_members, many=True)
serializer = ProjectMemberRoleSerializer(
project_members, fields=("id", "member", "role"), many=True
)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, pk):
@ -758,7 +777,9 @@ class ProjectMemberViewSet(BaseViewSet):
> requested_project_member.role
):
return Response(
{"error": "You cannot update a role that is higher than your own role"},
{
"error": "You cannot update a role that is higher than your own role"
},
status=status.HTTP_400_BAD_REQUEST,
)
@ -797,7 +818,9 @@ class ProjectMemberViewSet(BaseViewSet):
# User cannot deactivate higher role
if requesting_project_member.role < project_member.role:
return Response(
{"error": "You cannot remove a user having role higher than you"},
{
"error": "You cannot remove a user having role higher than you"
},
status=status.HTTP_400_BAD_REQUEST,
)
@ -848,7 +871,8 @@ class AddTeamToProjectEndpoint(BaseAPIView):
if len(team_members) == 0:
return Response(
{"error": "No such team exists"}, status=status.HTTP_400_BAD_REQUEST
{"error": "No such team exists"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
@ -895,7 +919,8 @@ class ProjectIdentifierEndpoint(BaseAPIView):
if name == "":
return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Name is required"},
status=status.HTTP_400_BAD_REQUEST,
)
exists = ProjectIdentifier.objects.filter(
@ -912,16 +937,23 @@ class ProjectIdentifierEndpoint(BaseAPIView):
if name == "":
return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
)
if Project.objects.filter(identifier=name, workspace__slug=slug).exists():
return Response(
{"error": "Cannot delete an identifier of an existing project"},
{"error": "Name is required"},
status=status.HTTP_400_BAD_REQUEST,
)
ProjectIdentifier.objects.filter(name=name, workspace__slug=slug).delete()
if Project.objects.filter(
identifier=name, workspace__slug=slug
).exists():
return Response(
{
"error": "Cannot delete an identifier of an existing project"
},
status=status.HTTP_400_BAD_REQUEST,
)
ProjectIdentifier.objects.filter(
name=name, workspace__slug=slug
).delete()
return Response(
status=status.HTTP_204_NO_CONTENT,
@ -939,7 +971,9 @@ class ProjectUserViewsEndpoint(BaseAPIView):
).first()
if project_member is None:
return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN)
return Response(
{"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN
)
view_props = project_member.view_props
default_props = project_member.default_props
@ -947,8 +981,12 @@ class ProjectUserViewsEndpoint(BaseAPIView):
sort_order = project_member.sort_order
project_member.view_props = request.data.get("view_props", view_props)
project_member.default_props = request.data.get("default_props", default_props)
project_member.preferences = request.data.get("preferences", preferences)
project_member.default_props = request.data.get(
"default_props", default_props
)
project_member.preferences = request.data.get(
"preferences", preferences
)
project_member.sort_order = request.data.get("sort_order", sort_order)
project_member.save()
@ -1010,18 +1048,11 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
def get(self, request):
files = []
s3_client_params = {
"service_name": "s3",
"aws_access_key_id": settings.AWS_ACCESS_KEY_ID,
"aws_secret_access_key": settings.AWS_SECRET_ACCESS_KEY,
}
# Use AWS_S3_ENDPOINT_URL if it is present in the settings
if hasattr(settings, "AWS_S3_ENDPOINT_URL") and settings.AWS_S3_ENDPOINT_URL:
s3_client_params["endpoint_url"] = settings.AWS_S3_ENDPOINT_URL
s3 = boto3.client(**s3_client_params)
s3 = boto3.client(
"s3",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
params = {
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
"Prefix": "static/project-cover/",
@ -1034,19 +1065,9 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
if not content["Key"].endswith(
"/"
): # This line ensures we're only getting files, not "sub-folders"
if (
hasattr(settings, "AWS_S3_CUSTOM_DOMAIN")
and settings.AWS_S3_CUSTOM_DOMAIN
and hasattr(settings, "AWS_S3_URL_PROTOCOL")
and settings.AWS_S3_URL_PROTOCOL
):
files.append(
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/{content['Key']}"
)
else:
files.append(
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
)
files.append(
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
)
return Response(files, status=status.HTTP_200_OK)
@ -1113,6 +1134,7 @@ class UserProjectRolesEndpoint(BaseAPIView):
).values("project_id", "role")
project_members = {
str(member["project_id"]): member["role"] for member in project_members
str(member["project_id"]): member["role"]
for member in project_members
}
return Response(project_members, status=status.HTTP_200_OK)

View File

@ -10,7 +10,15 @@ from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import Workspace, Project, Issue, Cycle, Module, Page, IssueView
from plane.db.models import (
Workspace,
Project,
Issue,
Cycle,
Module,
Page,
IssueView,
)
from plane.utils.issue_search import search_issues
@ -25,7 +33,9 @@ class GlobalSearchEndpoint(BaseAPIView):
for field in fields:
q |= Q(**{f"{field}__icontains": query})
return (
Workspace.objects.filter(q, workspace_member__member=self.request.user)
Workspace.objects.filter(
q, workspace_member__member=self.request.user
)
.distinct()
.values("name", "id", "slug")
)
@ -38,7 +48,8 @@ class GlobalSearchEndpoint(BaseAPIView):
return (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user) | Q(network=2),
Q(project_projectmember__member=self.request.user)
| Q(network=2),
workspace__slug=slug,
)
.distinct()
@ -169,7 +180,9 @@ class GlobalSearchEndpoint(BaseAPIView):
def get(self, request, slug):
query = request.query_params.get("search", False)
workspace_search = request.query_params.get("workspace_search", "false")
workspace_search = request.query_params.get(
"workspace_search", "false"
)
project_id = request.query_params.get("project_id", False)
if not query:
@ -209,11 +222,13 @@ class GlobalSearchEndpoint(BaseAPIView):
class IssueSearchEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
query = request.query_params.get("search", False)
workspace_search = request.query_params.get("workspace_search", "false")
workspace_search = request.query_params.get(
"workspace_search", "false"
)
parent = request.query_params.get("parent", "false")
issue_relation = request.query_params.get("issue_relation", "false")
cycle = request.query_params.get("cycle", "false")
module = request.query_params.get("module", "false")
module = request.query_params.get("module", False)
sub_issue = request.query_params.get("sub_issue", "false")
issue_id = request.query_params.get("issue_id", False)
@ -234,9 +249,9 @@ class IssueSearchEndpoint(BaseAPIView):
issues = issues.filter(
~Q(pk=issue_id), ~Q(pk=issue.parent_id), parent__isnull=True
).exclude(
pk__in=Issue.issue_objects.filter(parent__isnull=False).values_list(
"parent_id", flat=True
)
pk__in=Issue.issue_objects.filter(
parent__isnull=False
).values_list("parent_id", flat=True)
)
if issue_relation == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
@ -254,8 +269,8 @@ class IssueSearchEndpoint(BaseAPIView):
if cycle == "true":
issues = issues.exclude(issue_cycle__isnull=False)
if module == "true":
issues = issues.exclude(issue_module__isnull=False)
if module:
issues = issues.exclude(issue_module__module=module)
return Response(
issues.values(

View File

@ -9,9 +9,12 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
from . import BaseViewSet
from . import BaseViewSet, BaseAPIView
from plane.app.serializers import StateSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.app.permissions import (
ProjectEntityPermission,
WorkspaceEntityPermission,
)
from plane.db.models import State, Issue
@ -22,9 +25,6 @@ class StateViewSet(BaseViewSet):
ProjectEntityPermission,
]
def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id"))
def get_queryset(self):
return self.filter_queryset(
super()
@ -77,14 +77,19 @@ class StateViewSet(BaseViewSet):
)
if state.default:
return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "Default state cannot be deleted"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=pk).exists()
if issue_exist:
return Response(
{"error": "The state is not empty, only empty states can be deleted"},
{
"error": "The state is not empty, only empty states can be deleted"
},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -43,7 +43,9 @@ class UserEndpoint(BaseViewSet):
is_admin = InstanceAdmin.objects.filter(
instance=instance, user=request.user
).exists()
return Response({"is_instance_admin": is_admin}, status=status.HTTP_200_OK)
return Response(
{"is_instance_admin": is_admin}, status=status.HTTP_200_OK
)
def deactivate(self, request):
# Check all workspace user is active
@ -51,7 +53,12 @@ class UserEndpoint(BaseViewSet):
# Instance admin check
if InstanceAdmin.objects.filter(user=user).exists():
return Response({"error": "You cannot deactivate your account since you are an instance admin"}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{
"error": "You cannot deactivate your account since you are an instance admin"
},
status=status.HTTP_400_BAD_REQUEST,
)
projects_to_deactivate = []
workspaces_to_deactivate = []
@ -61,7 +68,10 @@ class UserEndpoint(BaseViewSet):
).annotate(
other_admin_exists=Count(
Case(
When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1),
When(
Q(role=20, is_active=True) & ~Q(member=request.user),
then=1,
),
default=0,
output_field=IntegerField(),
)
@ -86,7 +96,10 @@ class UserEndpoint(BaseViewSet):
).annotate(
other_admin_exists=Count(
Case(
When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1),
When(
Q(role=20, is_active=True) & ~Q(member=request.user),
then=1,
),
default=0,
output_field=IntegerField(),
)
@ -95,7 +108,9 @@ class UserEndpoint(BaseViewSet):
)
for workspace in workspaces:
if workspace.other_admin_exists > 0 or (workspace.total_members == 1):
if workspace.other_admin_exists > 0 or (
workspace.total_members == 1
):
workspace.is_active = False
workspaces_to_deactivate.append(workspace)
else:
@ -134,7 +149,9 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView):
user = User.objects.get(pk=request.user.id, is_active=True)
user.is_onboarded = request.data.get("is_onboarded", False)
user.save()
return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK)
return Response(
{"message": "Updated successfully"}, status=status.HTTP_200_OK
)
class UpdateUserTourCompletedEndpoint(BaseAPIView):
@ -142,14 +159,16 @@ class UpdateUserTourCompletedEndpoint(BaseAPIView):
user = User.objects.get(pk=request.user.id, is_active=True)
user.is_tour_completed = request.data.get("is_tour_completed", False)
user.save()
return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK)
return Response(
{"message": "Updated successfully"}, status=status.HTTP_200_OK
)
class UserActivityEndpoint(BaseAPIView, BasePaginator):
def get(self, request):
queryset = IssueActivity.objects.filter(actor=request.user).select_related(
"actor", "workspace", "issue", "project"
)
queryset = IssueActivity.objects.filter(
actor=request.user
).select_related("actor", "workspace", "issue", "project")
return self.paginate(
request=request,
@ -158,4 +177,3 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
issue_activities, many=True
).data,
)

View File

@ -24,10 +24,15 @@ from . import BaseViewSet, BaseAPIView
from plane.app.serializers import (
GlobalViewSerializer,
IssueViewSerializer,
IssueLiteSerializer,
IssueSerializer,
IssueViewFavoriteSerializer,
)
from plane.app.permissions import WorkspaceEntityPermission, ProjectEntityPermission
from plane.app.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission,
WorkspaceViewerPermission,
ProjectLitePermission,
)
from plane.db.models import (
Workspace,
GlobalView,
@ -37,14 +42,15 @@ from plane.db.models import (
IssueReaction,
IssueLink,
IssueAttachment,
IssueSubscriber,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.grouper import group_results
class GlobalViewViewSet(BaseViewSet):
serializer_class = GlobalViewSerializer
model = GlobalView
serializer_class = IssueViewSerializer
model = IssueView
permission_classes = [
WorkspaceEntityPermission,
]
@ -58,6 +64,7 @@ class GlobalViewViewSet(BaseViewSet):
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project__isnull=True)
.select_related("workspace")
.order_by(self.request.GET.get("order_by", "-created_at"))
.distinct()
@ -72,18 +79,16 @@ class GlobalViewIssuesViewSet(BaseViewSet):
def get_queryset(self):
return (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
@ -95,11 +100,21 @@ class GlobalViewIssuesViewSet(BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug):
filters = issue_filters(request.query_params, "GET")
fields = [field for field in request.GET.get("fields", "").split(",") if field]
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at")
@ -108,7 +123,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.filter(**filters)
.filter(project__project_projectmember__member=self.request.user)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@ -116,7 +130,17 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@ -126,7 +150,9 @@ class GlobalViewIssuesViewSet(BaseViewSet):
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order if order_by_param == "priority" else priority_order[::-1]
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
@ -174,17 +200,17 @@ class GlobalViewIssuesViewSet(BaseViewSet):
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(
issue_dict,
status=status.HTTP_200_OK,
serializer = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None
)
return Response(serializer.data, status=status.HTTP_200_OK)
class IssueViewViewSet(BaseViewSet):
@ -217,6 +243,18 @@ class IssueViewViewSet(BaseViewSet):
.distinct()
)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
views = IssueViewSerializer(
queryset, many=True, fields=fields if fields else None
).data
return Response(views, status=status.HTTP_200_OK)
class IssueViewFavoriteViewSet(BaseViewSet):
serializer_class = IssueViewFavoriteSerializer

View File

@ -26,8 +26,12 @@ class WebhookEndpoint(BaseAPIView):
)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
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(

View File

@ -41,13 +41,19 @@ from plane.app.serializers import (
ProjectMemberSerializer,
WorkspaceThemeSerializer,
IssueActivitySerializer,
IssueLiteSerializer,
IssueSerializer,
WorkspaceMemberAdminSerializer,
WorkspaceMemberMeSerializer,
ProjectMemberRoleSerializer,
WorkspaceUserPropertiesSerializer,
WorkspaceEstimateSerializer,
StateSerializer,
LabelSerializer,
)
from plane.app.views.base import BaseAPIView
from . import BaseViewSet
from plane.db.models import (
State,
User,
Workspace,
WorkspaceMemberInvite,
@ -64,6 +70,9 @@ from plane.db.models import (
WorkspaceMember,
CycleIssue,
IssueReaction,
WorkspaceUserProperties,
Estimate,
EstimatePoint,
)
from plane.app.permissions import (
WorkSpaceBasePermission,
@ -71,11 +80,13 @@ from plane.app.permissions import (
WorkspaceEntityPermission,
WorkspaceViewerPermission,
WorkspaceUserPermission,
ProjectLitePermission,
)
from plane.bgtasks.workspace_invitation_task import workspace_invitation
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.event_tracking_task import workspace_invite_event
class WorkSpaceViewSet(BaseViewSet):
model = Workspace
serializer_class = WorkSpaceSerializer
@ -111,7 +122,9 @@ class WorkSpaceViewSet(BaseViewSet):
.values("count")
)
return (
self.filter_queryset(super().get_queryset().select_related("owner"))
self.filter_queryset(
super().get_queryset().select_related("owner")
)
.order_by("name")
.filter(
workspace_member__member=self.request.user,
@ -137,7 +150,9 @@ class WorkSpaceViewSet(BaseViewSet):
if len(name) > 80 or len(slug) > 48:
return Response(
{"error": "The maximum length for name is 80 and for slug is 48"},
{
"error": "The maximum length for name is 80 and for slug is 48"
},
status=status.HTTP_400_BAD_REQUEST,
)
@ -150,7 +165,9 @@ class WorkSpaceViewSet(BaseViewSet):
role=20,
company_role=request.data.get("company_role", ""),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
serializer.data, status=status.HTTP_201_CREATED
)
return Response(
[serializer.errors[error][0] for error in serializer.errors],
status=status.HTTP_400_BAD_REQUEST,
@ -173,6 +190,11 @@ class UserWorkSpacesEndpoint(BaseAPIView):
]
def get(self, request):
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
member_count = (
WorkspaceMember.objects.filter(
workspace=OuterRef("id"),
@ -204,13 +226,17 @@ class UserWorkSpacesEndpoint(BaseAPIView):
.annotate(total_members=member_count)
.annotate(total_issues=issue_count)
.filter(
workspace_member__member=request.user, workspace_member__is_active=True
workspace_member__member=request.user,
workspace_member__is_active=True,
)
.distinct()
)
serializer = WorkSpaceSerializer(self.filter_queryset(workspace), many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
workspaces = WorkSpaceSerializer(
self.filter_queryset(workspace),
fields=fields if fields else None,
many=True,
).data
return Response(workspaces, status=status.HTTP_200_OK)
class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
@ -250,7 +276,8 @@ class WorkspaceInvitationsViewset(BaseViewSet):
# Check if email is provided
if not emails:
return Response(
{"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Emails are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# check for role level of the requesting user
@ -537,10 +564,15 @@ class WorkSpaceMemberViewSet(BaseViewSet):
workspace_members = self.get_queryset()
if workspace_member.role > 10:
serializer = WorkspaceMemberAdminSerializer(workspace_members, many=True)
serializer = WorkspaceMemberAdminSerializer(
workspace_members,
fields=("id", "member", "role"),
many=True,
)
else:
serializer = WorkSpaceMemberSerializer(
workspace_members,
fields=("id", "member", "role"),
many=True,
)
return Response(serializer.data, status=status.HTTP_200_OK)
@ -572,7 +604,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
> requested_workspace_member.role
):
return Response(
{"error": "You cannot update a role that is higher than your own role"},
{
"error": "You cannot update a role that is higher than your own role"
},
status=status.HTTP_400_BAD_REQUEST,
)
@ -611,7 +645,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
if requesting_workspace_member.role < workspace_member.role:
return Response(
{"error": "You cannot remove a user having role higher than you"},
{
"error": "You cannot remove a user having role higher than you"
},
status=status.HTTP_400_BAD_REQUEST,
)
@ -705,6 +741,49 @@ class WorkSpaceMemberViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class WorkspaceProjectMemberEndpoint(BaseAPIView):
serializer_class = ProjectMemberRoleSerializer
model = ProjectMember
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug):
# Fetch all project IDs where the user is involved
project_ids = (
ProjectMember.objects.filter(
member=request.user,
member__is_bot=False,
is_active=True,
)
.values_list("project_id", flat=True)
.distinct()
)
# Get all the project members in which the user is involved
project_members = ProjectMember.objects.filter(
workspace__slug=slug,
member__is_bot=False,
project_id__in=project_ids,
is_active=True,
).select_related("project", "member", "workspace")
project_members = ProjectMemberRoleSerializer(
project_members, many=True
).data
project_members_dict = dict()
# Construct a dictionary with project_id as key and project_members as value
for project_member in project_members:
project_id = project_member.pop("project")
if str(project_id) not in project_members_dict:
project_members_dict[str(project_id)] = []
project_members_dict[str(project_id)].append(project_member)
return Response(project_members_dict, status=status.HTTP_200_OK)
class TeamMemberViewSet(BaseViewSet):
serializer_class = TeamSerializer
model = Team
@ -739,7 +818,9 @@ class TeamMemberViewSet(BaseViewSet):
)
if len(members) != len(request.data.get("members", [])):
users = list(set(request.data.get("members", [])).difference(members))
users = list(
set(request.data.get("members", [])).difference(members)
)
users = User.objects.filter(pk__in=users)
serializer = UserLiteSerializer(users, many=True)
@ -753,7 +834,9 @@ class TeamMemberViewSet(BaseViewSet):
workspace = Workspace.objects.get(slug=slug)
serializer = TeamSerializer(data=request.data, context={"workspace": workspace})
serializer = TeamSerializer(
data=request.data, context={"workspace": workspace}
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
@ -782,7 +865,9 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
workspace_id=last_workspace_id, member=request.user
).select_related("workspace", "project", "member", "workspace__owner")
project_member_serializer = ProjectMemberSerializer(project_member, many=True)
project_member_serializer = ProjectMemberSerializer(
project_member, many=True
)
return Response(
{
@ -966,7 +1051,11 @@ class WorkspaceThemeViewSet(BaseViewSet):
serializer_class = WorkspaceThemeSerializer
def get_queryset(self):
return super().get_queryset().filter(workspace__slug=self.kwargs.get("slug"))
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
)
def create(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
@ -1229,12 +1318,22 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
]
def get(self, request, slug, user_id):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
@ -1246,21 +1345,9 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
project__project_projectmember__member=request.user,
)
.filter(**filters)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.order_by("-created_at")
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@ -1268,17 +1355,30 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.order_by("created_at")
).distinct()
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order if order_by_param == "priority" else priority_order[::-1]
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
@ -1326,16 +1426,17 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(
issues = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None
).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(issue_dict, status=status.HTTP_200_OK)
return Response(issues, status=status.HTTP_200_OK)
class WorkspaceLabelsEndpoint(BaseAPIView):
@ -1347,5 +1448,79 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
labels = Label.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
).values("parent", "name", "color", "id", "project_id", "workspace__slug")
return Response(labels, status=status.HTTP_200_OK)
)
serializer = LabelSerializer(labels, many=True).data
return Response(serializer, status=status.HTTP_200_OK)
class WorkspaceStatesEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug):
states = State.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
)
serializer = StateSerializer(states, many=True).data
return Response(serializer, status=status.HTTP_200_OK)
class WorkspaceEstimatesEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug):
estimate_ids = Project.objects.filter(
workspace__slug=slug, estimate__isnull=False
).values_list("estimate_id", flat=True)
estimates = Estimate.objects.filter(
pk__in=estimate_ids
).prefetch_related(
Prefetch(
"points",
queryset=EstimatePoint.objects.select_related(
"estimate", "workspace", "project"
),
)
)
serializer = WorkspaceEstimateSerializer(estimates, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class WorkspaceUserPropertiesEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
]
def patch(self, request, slug):
workspace_properties = WorkspaceUserProperties.objects.get(
user=request.user,
workspace__slug=slug,
)
workspace_properties.filters = request.data.get(
"filters", workspace_properties.filters
)
workspace_properties.display_filters = request.data.get(
"display_filters", workspace_properties.display_filters
)
workspace_properties.display_properties = request.data.get(
"display_properties", workspace_properties.display_properties
)
workspace_properties.save()
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug):
(
workspace_properties,
_,
) = WorkspaceUserProperties.objects.get_or_create(
user=request.user, workspace__slug=slug
)
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
return Response(serializer.data, status=status.HTTP_200_OK)

Some files were not shown because too many files have changed in this diff Show More