Compare commits

..

14 Commits

Author SHA1 Message Date
sriram veeraghanta 707570ca7a Merge pull request #5041 from makeplane/preview
release: v0.22-dev
2024-07-05 13:28:45 +05:30
sriram veeraghanta c76af7d7d6 Merge pull request #4688 from makeplane/preview
release: v0.21-dev
2024-06-03 18:54:06 +05:30
sriram veeraghanta 1dcea9bcc8 Merge pull request #4569 from makeplane/preview
release: v0.20-dev
2024-05-23 19:55:06 +05:30
sriram veeraghanta da957e06b6 Merge pull request #4349 from makeplane/preview
release: v0.19-dev
2024-05-03 20:36:07 +05:30
sriram veeraghanta a0b9596cb4 Merge pull request #4239 from makeplane/preview
chore:version update
2024-04-19 12:01:15 +05:30
sriram veeraghanta f71e8a3a0f Merge pull request #4238 from makeplane/preview
release: v0.18-dev
2024-04-19 11:56:03 +05:30
sriram veeraghanta 002fb4547b Merge pull request #4107 from makeplane/preview
release: v0.17-dev
2024-04-02 20:07:48 +05:30
sriram veeraghanta c1b1ba35c1 Merge pull request #3878 from makeplane/preview
release: v0.16-dev
2024-03-05 20:04:08 +05:30
sriram veeraghanta 4566d6e80c Merge pull request #3697 from makeplane/preview
release: 0.15.4-dev
2024-02-19 19:30:06 +05:30
sriram veeraghanta e8d359e625 Merge pull request #3674 from makeplane/preview
fix: build branch docker images push on release
2024-02-15 14:35:32 +05:30
sriram veeraghanta 351eba8d61 Merge pull request #3671 from makeplane/preview
release: peek overview issue description initial load bug (#3670)
2024-02-15 03:25:30 +05:30
sriram veeraghanta 1e27e37b51 Merge pull request #3666 from makeplane/preview
release: v0.15.2-dev
2024-02-14 19:41:55 +05:30
sriram veeraghanta 7df2e9cf11 Merge pull request #3632 from makeplane/preview
release: v0.15.1-dev
2024-02-12 20:59:56 +05:30
sriram veeraghanta c6e3f1b932 Merge pull request #3535 from makeplane/preview
release: 0.15-dev
2024-02-01 15:01:49 +05:30
1384 changed files with 24890 additions and 41642 deletions
-7
View File
@@ -8,13 +8,6 @@ PGDATA="/var/lib/postgresql/data"
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
# RabbitMQ Settings
RABBITMQ_HOST="plane-mq"
RABBITMQ_PORT="5672"
RABBITMQ_USER="plane"
RABBITMQ_PASSWORD="plane"
RABBITMQ_VHOST="plane"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
-1
View File
@@ -1 +0,0 @@
*.sh text eol=lf
+1 -1
View File
@@ -2,7 +2,7 @@ name: Bug report
description: Create a bug report to help us improve Plane
title: "[bug]: "
labels: [🐛bug]
assignees: [vihar, pushya22]
assignees: [srinivaspendem, pushya22]
body:
- type: markdown
attributes:
@@ -2,7 +2,7 @@ name: Feature request
description: Suggest a feature to improve Plane
title: "[feature]: "
labels: [✨feature]
assignees: [vihar, pushya22]
assignees: [srinivaspendem, pushya22]
body:
- type: markdown
attributes:
+5 -9
View File
@@ -31,7 +31,6 @@ jobs:
runs-on: ubuntu-latest
outputs:
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }}
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
@@ -57,7 +56,7 @@ jobs:
echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT
echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
if [ "${{ github.event_name}}" == "workflow_dispatch" ] && [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then
if [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then
echo "AIO_BASE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT
elif [ "${{ env.TARGET_BRANCH }}" == "preview" ]; then
echo "AIO_BASE_TAG=preview" >> $GITHUB_OUTPUT
@@ -79,9 +78,6 @@ jobs:
echo "DO_SLIM_BUILD=false" >> $GITHUB_OUTPUT
fi
FLAT_BRANCH_NAME=$(echo "${{ env.TARGET_BRANCH }}" | sed 's/[^a-zA-Z0-9]/-/g')
echo "FLAT_BRANCH_NAME=$FLAT_BRANCH_NAME" >> $GITHUB_OUTPUT
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
@@ -93,7 +89,7 @@ jobs:
env:
BUILD_TYPE: full
AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }}
AIO_IMAGE_TAGS: makeplane/plane-aio:full-${{ needs.branch_build_setup.outputs.flat_branch_name }}
AIO_IMAGE_TAGS: makeplane/plane-aio:full-${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -136,7 +132,7 @@ jobs:
tags: ${{ env.AIO_IMAGE_TAGS }}
push: true
build-args: |
BASE_TAG=${{ env.AIO_BASE_TAG }}
BUILD_TAG=${{ env.AIO_BASE_TAG }}
BUILD_TYPE=${{env.BUILD_TYPE}}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -153,7 +149,7 @@ jobs:
env:
BUILD_TYPE: slim
AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }}
AIO_IMAGE_TAGS: makeplane/plane-aio:slim-${{ needs.branch_build_setup.outputs.flat_branch_name }}
AIO_IMAGE_TAGS: makeplane/plane-aio:slim-${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -196,7 +192,7 @@ jobs:
tags: ${{ env.AIO_IMAGE_TAGS }}
push: true
build-args: |
BASE_TAG=${{ env.AIO_BASE_TAG }}
BUILD_TAG=${{ env.AIO_BASE_TAG }}
BUILD_TYPE=${{env.BUILD_TYPE}}
cache-from: type=gha
cache-to: type=gha,mode=max
+11 -106
View File
@@ -2,12 +2,6 @@ name: Branch Build
on:
workflow_dispatch:
inputs:
arm64:
description: "Build for ARM64 architecture"
required: false
default: false
type: boolean
push:
branches:
- master
@@ -17,8 +11,6 @@ on:
env:
TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }}
ARM64_BUILD: ${{ github.event.inputs.arm64 }}
IS_PRERELEASE: ${{ github.event.release.prerelease }}
jobs:
branch_build_setup:
@@ -35,14 +27,12 @@ jobs:
build_admin: ${{ steps.changed_files.outputs.admin_any_changed }}
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
build_web: ${{ steps.changed_files.outputs.web_any_changed }}
build_live: ${{ steps.changed_files.outputs.live_any_changed }}
flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }}
steps:
- id: set_env_variables
name: Set Environment Variables
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ env.ARM64_BUILD }}" == "true" ] || ([ "${{ github.event_name }}" == "release" ] && [ "${{ env.IS_PRERELEASE }}" != "true" ]); then
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ github.event_name }}" == "release" ]; then
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
@@ -54,8 +44,6 @@ jobs:
echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
fi
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
flat_branch_name=$(echo ${{ env.TARGET_BRANCH }} | sed 's/[^a-zA-Z0-9\._]/-/g')
echo "FLAT_BRANCH_NAME=${flat_branch_name}" >> $GITHUB_OUTPUT
- id: checkout_files
name: Checkout Files
@@ -91,21 +79,13 @@ jobs:
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
live:
- live/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
branch_build_push_web:
if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Web Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
FRONTEND_TAG: makeplane/plane-frontend:${{ needs.branch_build_setup.outputs.flat_branch_name }}
FRONTEND_TAG: makeplane/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -115,10 +95,7 @@ jobs:
- name: Set Frontend Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-frontend:${{ github.event.release.tag_name }}
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
TAG=${TAG},makeplane/plane-frontend:stable
fi
TAG=makeplane/plane-frontend:stable,makeplane/plane-frontend:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-frontend:latest
else
@@ -157,11 +134,10 @@ jobs:
branch_build_push_admin:
if: ${{ needs.branch_build_setup.outputs.build_admin== 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Admin Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
ADMIN_TAG: makeplane/plane-admin:${{ needs.branch_build_setup.outputs.flat_branch_name }}
ADMIN_TAG: makeplane/plane-admin:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -171,10 +147,7 @@ jobs:
- name: Set Admin Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-admin:${{ github.event.release.tag_name }}
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
TAG=${TAG},makeplane/plane-admin:stable
fi
TAG=makeplane/plane-admin:stable,makeplane/plane-admin:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-admin:latest
else
@@ -213,11 +186,10 @@ jobs:
branch_build_push_space:
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Space Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
SPACE_TAG: makeplane/plane-space:${{ needs.branch_build_setup.outputs.flat_branch_name }}
SPACE_TAG: makeplane/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -227,10 +199,7 @@ jobs:
- name: Set Space Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-space:${{ github.event.release.tag_name }}
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
TAG=${TAG},makeplane/plane-space:stable
fi
TAG=makeplane/plane-space:stable,makeplane/plane-space:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-space:latest
else
@@ -269,11 +238,10 @@ jobs:
branch_build_push_apiserver:
if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push API Server Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
BACKEND_TAG: makeplane/plane-backend:${{ needs.branch_build_setup.outputs.flat_branch_name }}
BACKEND_TAG: makeplane/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -283,10 +251,7 @@ jobs:
- name: Set Backend Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-backend:${{ github.event.release.tag_name }}
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
TAG=${TAG},makeplane/plane-backend:stable
fi
TAG=makeplane/plane-backend:stable,makeplane/plane-backend:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-backend:latest
else
@@ -323,69 +288,12 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_live:
if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Live Collaboration Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
LIVE_TAG: makeplane/plane-live:${{ needs.branch_build_setup.outputs.flat_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Set Live Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-live:${{ github.event.release.tag_name }}
if [ "${{ github.event.release.prerelease }}" != "true" ]; then
TAG=${TAG},makeplane/plane-live:stable
fi
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-live:latest
else
TAG=${{ env.LIVE_TAG }}
fi
echo "LIVE_TAG=${TAG}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v4
- name: Build and Push Live Server to Docker Hub
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./live/Dockerfile.live
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.LIVE_TAG }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_proxy:
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Proxy Docker Image
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
PROXY_TAG: makeplane/plane-proxy:${{ needs.branch_build_setup.outputs.flat_branch_name }}
PROXY_TAG: makeplane/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -395,10 +303,7 @@ jobs:
- name: Set Proxy Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-proxy:${{ github.event.release.tag_name }}
if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then
TAG=${TAG},makeplane/plane-proxy:stable
fi
TAG=makeplane/plane-proxy:stable,makeplane/plane-proxy:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-proxy:latest
else
-16
View File
@@ -1,16 +0,0 @@
{ pkgs, ... }: {
# Which nixpkgs channel to use.
channel = "stable-23.11"; # or "unstable"
# Use https://search.nixos.org/packages to find packages
packages = [
pkgs.nodejs_20
pkgs.python3
];
services.docker.enable = true;
services.postgres.enable = true;
services.redis.enable = true;
}
+62 -23
View File
@@ -1,5 +1,6 @@
# Environment Variables
Environment variables are distributed in various files. Please refer them carefully.
## {PROJECT_FOLDER}/.env
@@ -8,13 +9,17 @@ File is available in the project root folder
```
# Database Settings
POSTGRES_USER="plane"
POSTGRES_PASSWORD="plane"
POSTGRES_DB="plane"
PGDATA="/var/lib/postgresql/data"
PGUSER="plane"
PGPASSWORD="plane"
PGHOST="plane-db"
PGDATABASE="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
# Redis Settings
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
@@ -24,39 +29,63 @@ AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # deprecated
# Settings related to Docker
DOCKERIZED=1 # deprecated
# set to 1 If using the pre-configured minio setup
USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
```
## {PROJECT_FOLDER}/web/.env.example
```
# Public boards deploy URL
NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"
```
## {PROJECT_FOLDER}/apiserver/.env
```
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
CORS_ALLOWED_ORIGINS="http://localhost"
# Error logs
SENTRY_DSN=""
SENTRY_ENVIRONMENT="development"
# Database Settings
POSTGRES_USER="plane"
POSTGRES_PASSWORD="plane"
POSTGRES_HOST="plane-db"
POSTGRES_DB="plane"
POSTGRES_PORT=5432
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
PGUSER="plane"
PGPASSWORD="plane"
PGHOST="plane-db"
PGDATABASE="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
# Redis Settings
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/"
# Email Settings
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS="1"
EMAIL_USE_SSL="0"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
@@ -66,25 +95,35 @@ AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # deprecated
# Settings related to Docker
DOCKERIZED=1 # deprecated
DOCKERIZED=1 # Deprecated
# Github
GITHUB_CLIENT_SECRET="" # For fetching release notes
# set to 1 If using the pre-configured minio setup
USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# Email redirections and minio domain settings
# SignUps
ENABLE_SIGNUP="1"
# Email Redirection URL
WEB_URL="http://localhost"
# Gunicorn Workers
GUNICORN_WORKERS=2
# Base URLs
ADMIN_BASE_URL=
SPACE_BASE_URL=
APP_BASE_URL=
SECRET_KEY="gxoytl7dmnc1y37zahah820z5iq3iozu38cnfjtu3yaau9cd9z"
```
## Updates
- The environment variable NEXT_PUBLIC_API_BASE_URL has been removed from both the web and space projects.
- The naming convention for containers and images has been updated.
- The plane-worker image will no longer be maintained, as it has been merged with plane-backend.
- The Tiptap pro-extension dependency has been removed, eliminating the need for Tiptap API keys.
+4 -18
View File
@@ -9,9 +9,8 @@ import { IInstance, IInstanceAdmin } from "@plane/types";
import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components
import { ControllerInput } from "@/components/common";
import { useInstance } from "@/hooks/store";
import { IntercomConfig } from "./intercom";
// hooks
import { useInstance } from "@/hooks/store";
export interface IGeneralConfigurationForm {
instance: IInstance;
@@ -21,13 +20,11 @@ export interface IGeneralConfigurationForm {
export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer((props) => {
const { instance, instanceAdmins } = props;
// hooks
const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance();
const { updateInstanceInfo } = useInstance();
// form data
const {
handleSubmit,
control,
watch,
formState: { errors, isSubmitting },
} = useForm<Partial<IInstance>>({
defaultValues: {
@@ -39,16 +36,7 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
const onSubmit = async (formData: Partial<IInstance>) => {
const payload: Partial<IInstance> = { ...formData };
// update the intercom configuration
const isIntercomEnabled =
instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1";
if (!payload.is_telemetry_enabled && isIntercomEnabled) {
try {
await updateInstanceConfigurations({ IS_INTERCOM_ENABLED: "0" });
} catch (error) {
console.error(error);
}
}
console.log("payload", payload);
await updateInstanceInfo(payload)
.then(() =>
@@ -86,7 +74,6 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
value={instanceAdmins[0]?.user_detail?.email ?? ""}
placeholder="Admin email"
className="w-full cursor-not-allowed !text-custom-text-400"
autoComplete="on"
disabled
/>
</div>
@@ -106,8 +93,7 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
</div>
<div className="space-y-3">
<div className="text-lg font-medium">Chat + telemetry</div>
<IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
<div className="text-lg font-medium">Telemetry</div>
<div className="flex items-center gap-14 px-4 py-3 border border-custom-border-200 rounded">
<div className="grow flex items-center gap-4">
<div className="shrink-0">
-82
View File
@@ -1,82 +0,0 @@
"use client";
import { FC, useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { MessageSquare } from "lucide-react";
import { IFormattedInstanceConfiguration } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
// hooks
import { useInstance } from "@/hooks/store";
type TIntercomConfig = {
isTelemetryEnabled: boolean;
};
export const IntercomConfig: FC<TIntercomConfig> = observer((props) => {
const { isTelemetryEnabled } = props;
// hooks
const { instanceConfigurations, updateInstanceConfigurations, fetchInstanceConfigurations } = useInstance();
// states
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// derived values
const isIntercomEnabled = isTelemetryEnabled
? instanceConfigurations
? instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1"
? true
: false
: undefined
: false;
const { isLoading } = useSWR(isTelemetryEnabled ? "INSTANCE_CONFIGURATIONS" : null, () =>
isTelemetryEnabled ? fetchInstanceConfigurations() : null
);
const initialLoader = isLoading && isIntercomEnabled === undefined;
const submitInstanceConfigurations = async (payload: Partial<IFormattedInstanceConfiguration>) => {
try {
await updateInstanceConfigurations(payload);
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};
const enableIntercomConfig = () => {
submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" });
};
return (
<>
<div className="flex items-center gap-14 px-4 py-3 border border-custom-border-200 rounded">
<div className="grow flex items-center gap-4">
<div className="shrink-0">
<div className="flex items-center justify-center w-10 h-10 bg-custom-background-80 rounded-full">
<MessageSquare className="w-6 h-6 text-custom-text-300/80 p-0.5" />
</div>
</div>
<div className="grow">
<div className="text-sm font-medium text-custom-text-100 leading-5">Talk to Plane</div>
<div className="text-xs font-normal text-custom-text-300 leading-5">
Let your members chat with us via Intercom or another service. Toggling Telemetry off turns this off
automatically.
</div>
</div>
<div className="ml-auto">
<ToggleSwitch
value={isIntercomEnabled ? true : false}
onChange={enableIntercomConfig}
size="sm"
disabled={!isTelemetryEnabled || isSubmitting || initialLoader}
/>
</div>
</div>
</div>
</>
);
});
+1 -1
View File
@@ -7,7 +7,7 @@ import { GeneralConfigurationForm } from "./form";
function GeneralPage() {
const { instance, instanceAdmins } = useInstance();
console.log("instance", instance);
return (
<>
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
@@ -10,9 +10,8 @@ import {
// components
import { AuthenticationMethodCard } from "@/components/authentication";
// helpers
import { UpgradeButton } from "@/components/common/upgrade-button";
import { getBaseAuthenticationModes } from "@/helpers/authentication.helper";
// plane admin components
import { UpgradeButton } from "@/plane-admin/components/common";
// images
import OIDCLogo from "@/public/logos/oidc-logo.svg";
import SAMLLogo from "@/public/logos/saml-logo.svg";
@@ -28,24 +27,24 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) =>
updateConfig,
resolvedTheme,
}) => [
...getBaseAuthenticationModes({ disabled, updateConfig, resolvedTheme }),
{
key: "oidc",
name: "OIDC",
description: "Authenticate your users via the OpenID Connect protocol.",
icon: <Image src={OIDCLogo} height={22} width={22} alt="OIDC Logo" />,
config: <UpgradeButton />,
unavailable: true,
},
{
key: "saml",
name: "SAML",
description: "Authenticate your users via the Security Assertion Markup Language protocol.",
icon: <Image src={SAMLLogo} height={22} width={22} alt="SAML Logo" className="pl-0.5" />,
config: <UpgradeButton />,
unavailable: true,
},
];
...getBaseAuthenticationModes({ disabled, updateConfig, resolvedTheme }),
{
key: "oidc",
name: "OIDC",
description: "Authenticate your users via the OpenID Connect protocol.",
icon: <Image src={OIDCLogo} height={22} width={22} alt="OIDC Logo" />,
config: <UpgradeButton />,
unavailable: true,
},
{
key: "saml",
name: "SAML",
description: "Authenticate your users via the Security Assertion Markup Language protocol.",
icon: <Image src={SAMLLogo} height={22} width={22} alt="SAML Logo" className="pl-0.5" />,
config: <UpgradeButton />,
unavailable: true,
},
];
export const AuthenticationModes: React.FC<TAuthenticationModeProps> = observer((props) => {
const { disabled, updateConfig } = props;
-1
View File
@@ -1 +0,0 @@
export * from "./upgrade-button";
-19
View File
@@ -1,19 +0,0 @@
import { enableStaticRendering } from "mobx-react";
// stores
import { CoreRootStore } from "@/store/root.store";
enableStaticRendering(typeof window === "undefined");
export class RootStore extends CoreRootStore {
constructor() {
super();
}
hydrate(initialData: any) {
super.hydrate(initialData);
}
resetOnSignOut() {
super.resetOnSignOut();
}
}
@@ -96,7 +96,7 @@ export const HelpSection: FC = observer(() => {
leaveTo="transform opacity-0 scale-95"
>
<div
className={`absolute bottom-2 min-w-[10rem] z-[15] ${
className={`absolute bottom-2 min-w-[10rem] ${
isSidebarCollapsed ? "left-full" : "-left-[75px]"
} divide-y divide-custom-border-200 whitespace-nowrap rounded bg-custom-background-100 p-1 shadow-custom-shadow-xs`}
ref={helpOptionsRef}
@@ -1,29 +0,0 @@
import { FC } from "react";
import { Info, X } from "lucide-react";
// helpers
import { TAuthErrorInfo } from "@/helpers/authentication.helper";
type TAuthBanner = {
bannerData: TAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
};
export const AuthBanner: FC<TAuthBanner> = (props) => {
const { bannerData, handleBannerData } = props;
if (!bannerData) return <></>;
return (
<div className="relative flex items-center p-2 rounded-md gap-2 border border-custom-primary-100/50 bg-custom-primary-100/10">
<div className="w-4 h-4 flex-shrink-0 relative flex justify-center items-center">
<Info size={16} className="text-custom-primary-100" />
</div>
<div className="w-full text-sm font-medium text-custom-primary-100">{bannerData?.message}</div>
<div
className="relative ml-auto w-6 h-6 rounded-sm flex justify-center items-center transition-all cursor-pointer hover:bg-custom-primary-100/20 text-custom-primary-100/80"
onClick={() => handleBannerData && handleBannerData(undefined)}
>
<X className="w-4 h-4 flex-shrink-0" />
</div>
</div>
);
};
@@ -1,4 +1,3 @@
export * from "./auth-banner";
export * from "./email-config-switch";
export * from "./password-config-switch";
export * from "./authentication-method-card";
+1
View File
@@ -8,3 +8,4 @@ export * from "./empty-state";
export * from "./logo-spinner";
export * from "./page-header";
export * from "./code-block";
export * from "./upgrade-button";
@@ -174,7 +174,6 @@ export const InstanceSetupForm: FC = (props) => {
placeholder="Wilber"
value={formData.first_name}
onChange={(e) => handleFormChange("first_name", e.target.value)}
autoComplete="on"
autoFocus
/>
</div>
@@ -191,7 +190,6 @@ export const InstanceSetupForm: FC = (props) => {
placeholder="Wright"
value={formData.last_name}
onChange={(e) => handleFormChange("last_name", e.target.value)}
autoComplete="on"
/>
</div>
</div>
@@ -210,7 +208,6 @@ export const InstanceSetupForm: FC = (props) => {
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false}
autoComplete="on"
/>
{errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && (
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
@@ -250,7 +247,6 @@ export const InstanceSetupForm: FC = (props) => {
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="on"
/>
{showPassword.password ? (
<button
+3 -25
View File
@@ -8,16 +8,8 @@ import { Button, Input, Spinner } from "@plane/ui";
// components
import { Banner } from "@/components/common";
// helpers
import {
authErrorHandler,
EAuthenticationErrorCodes,
EErrorAlertType,
TAuthErrorInfo,
} from "@/helpers/authentication.helper";
import { API_BASE_URL } from "@/helpers/common.helper";
import { AuthService } from "@/services/auth.service";
import { AuthBanner } from "../authentication";
// ui
// icons
@@ -61,11 +53,12 @@ export const InstanceSignInForm: FC = (props) => {
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [formData, setFormData] = useState<TFormData>(defaultFromData);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
console.log("csrfToken", csrfToken);
useEffect(() => {
if (csrfToken === undefined)
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
@@ -100,15 +93,6 @@ export const InstanceSignInForm: FC = (props) => {
[formData.email, formData.password, isSubmitting]
);
useEffect(() => {
if (errorCode) {
const errorDetail = authErrorHandler(errorCode?.toString() as EAuthenticationErrorCodes);
if (errorDetail) {
setErrorInfo(errorDetail);
}
}
}, [errorCode]);
return (
<div className="flex-grow container mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 py-10 lg:pt-28 transition-all">
<div className="relative flex flex-col space-y-6">
@@ -121,11 +105,7 @@ export const InstanceSignInForm: FC = (props) => {
</p>
</div>
{errorData.type && errorData?.message ? (
<Banner type="error" message={errorData?.message} />
) : (
<>{errorInfo && <AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />}</>
)}
{errorData.type && errorData?.message && <Banner type="error" message={errorData?.message} />}
<form
className="space-y-4"
@@ -149,7 +129,6 @@ export const InstanceSignInForm: FC = (props) => {
placeholder="name@company.com"
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
autoComplete="on"
autoFocus
/>
</div>
@@ -168,7 +147,6 @@ export const InstanceSignInForm: FC = (props) => {
placeholder="Enter your password"
value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
autoComplete="on"
/>
{showPassword ? (
<button
-1
View File
@@ -18,7 +18,6 @@ export const AdminLayout: FC<TAdminLayout> = observer((props) => {
const { children } = props;
// router
const router = useRouter();
// store hooks
const { isUserLoggedIn } = useUser();
useEffect(() => {
+2 -2
View File
@@ -1,8 +1,8 @@
"use client";
import { ReactNode, createContext } from "react";
// plane admin store
import { RootStore } from "@/plane-admin/store/root.store";
// store
import { RootStore } from "@/store/root.store";
let rootStore = new RootStore();
+2 -2
View File
@@ -13,7 +13,7 @@ import { EInstanceStatus, TInstanceStatus } from "@/helpers/instance.helper";
// services
import { InstanceService } from "@/services/instance.service";
// root store
import { CoreRootStore } from "@/store/root.store";
import { RootStore } from "@/store/root.store";
export interface IInstanceStore {
// issues
@@ -46,7 +46,7 @@ export class InstanceStore implements IInstanceStore {
// service
instanceService;
constructor(private store: CoreRootStore) {
constructor(private store: RootStore) {
makeObservable(this, {
// observable
isLoading: observable.ref,
+1 -1
View File
@@ -6,7 +6,7 @@ import { IUserStore, UserStore } from "./user.store";
enableStaticRendering(typeof window === "undefined");
export abstract class CoreRootStore {
export class RootStore {
theme: IThemeStore;
instance: IInstanceStore;
user: IUserStore;
+2 -2
View File
@@ -1,6 +1,6 @@
import { action, observable, makeObservable } from "mobx";
// root store
import { CoreRootStore } from "@/store/root.store";
import { RootStore } from "@/store/root.store";
type TTheme = "dark" | "light";
export interface IThemeStore {
@@ -21,7 +21,7 @@ export class ThemeStore implements IThemeStore {
isSidebarCollapsed: boolean | undefined = undefined;
theme: string | undefined = undefined;
constructor(private store: CoreRootStore) {
constructor(private store: RootStore) {
makeObservable(this, {
// observables
isNewUserPopup: observable.ref,
+2 -2
View File
@@ -6,7 +6,7 @@ import { EUserStatus, TUserStatus } from "@/helpers/user.helper";
import { AuthService } from "@/services/auth.service";
import { UserService } from "@/services/user.service";
// root store
import { CoreRootStore } from "@/store/root.store";
import { RootStore } from "@/store/root.store";
export interface IUserStore {
// observables
@@ -31,7 +31,7 @@ export class UserStore implements IUserStore {
userService;
authService;
constructor(private store: CoreRootStore) {
constructor(private store: RootStore) {
makeObservable(this, {
// observables
isLoading: observable.ref,
-1
View File
@@ -1 +0,0 @@
export * from "ce/components/common";
-1
View File
@@ -1 +0,0 @@
export * from "ce/store/root.store";
+2 -3
View File
@@ -14,11 +14,10 @@
"@headlessui/react": "^1.7.19",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/constants": "*",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
"axios": "^1.7.4",
"axios": "^1.6.7",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lucide-react": "^0.356.0",
@@ -29,7 +28,7 @@
"postcss": "^8.4.38",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "7.51.5",
"react-hook-form": "^7.51.0",
"swr": "^2.2.4",
"tailwindcss": "3.3.2",
"uuid": "^9.0.1",
+1 -10
View File
@@ -15,18 +15,12 @@ POSTGRES_DB="plane"
POSTGRES_PORT=5432
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
# Redis Settings
REDIS_HOST="plane-redis"
REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/"
# RabbitMQ Settings
RABBITMQ_HOST="plane-mq"
RABBITMQ_PORT="5672"
RABBITMQ_USER="plane"
RABBITMQ_PASSWORD="plane"
RABBITMQ_VHOST="plane"
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
@@ -56,6 +50,3 @@ GUNICORN_WORKERS=2
ADMIN_BASE_URL=
SPACE_BASE_URL=
APP_BASE_URL=
# Hard delete files after days
HARD_DELETE_AFTER_DAYS=60
+12 -12
View File
@@ -1,4 +1,4 @@
FROM python:3.12.5-alpine AS backend
FROM python:3.11.1-alpine3.17 AS backend
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
@@ -7,23 +7,23 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /code
RUN apk add --no-cache \
"libpq" \
"libxslt" \
"nodejs-current" \
"xmlsec"
RUN apk --no-cache add \
"libpq~=15" \
"libxslt~=1.1" \
"nodejs-current~=19" \
"xmlsec~=1.2"
COPY requirements.txt ./
COPY requirements ./requirements
RUN apk add --no-cache libffi-dev
RUN apk add --no-cache --virtual .build-deps \
"bash~=5.2" \
"g++" \
"gcc" \
"cargo" \
"git" \
"make" \
"postgresql-dev" \
"g++~=12.2" \
"gcc~=12.2" \
"cargo~=1.64" \
"git~=2" \
"make~=4.3" \
"postgresql13-dev~=13" \
"libc-dev" \
"linux-headers" \
&& \
+11 -11
View File
@@ -1,4 +1,4 @@
FROM python:3.12.5-alpine AS backend
FROM python:3.11.1-alpine3.17 AS backend
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
@@ -7,18 +7,18 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
RUN apk --no-cache add \
"bash~=5.2" \
"libpq" \
"libxslt" \
"nodejs-current" \
"xmlsec" \
"libpq~=15" \
"libxslt~=1.1" \
"nodejs-current~=19" \
"xmlsec~=1.2" \
"libffi-dev" \
"bash~=5.2" \
"g++" \
"gcc" \
"cargo" \
"git" \
"make" \
"postgresql-dev" \
"g++~=12.2" \
"gcc~=12.2" \
"cargo~=1.64" \
"git~=2" \
"make~=4.3" \
"postgresql13-dev~=13" \
"libc-dev" \
"linux-headers"
@@ -32,3 +32,4 @@ python manage.py create_bucket
python manage.py clear_cache
python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local
-41
View File
@@ -40,44 +40,3 @@ class ApiKeyRateThrottle(SimpleRateThrottle):
request.META["X-RateLimit-Reset"] = reset_time
return allowed
class ServiceTokenRateThrottle(SimpleRateThrottle):
scope = "service_token"
rate = "300/minute"
def get_cache_key(self, request, view):
# Retrieve the API key from the request header
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}"
def allow_request(self, request, view):
allowed = super().allow_request(request, view)
if allowed:
now = self.timer()
# Calculate the remaining limit and reset time
history = self.cache.get(self.key, [])
# Remove old histories
while history and history[-1] <= now - self.duration:
history.pop()
# Calculate the requests
num_requests = len(history)
# Check available requests
available = self.num_requests - num_requests
# Unix timestamp for when the rate limit will reset
reset_time = int(now + self.duration)
# Add headers
request.META["X-RateLimit-Remaining"] = max(0, available)
request.META["X-RateLimit-Reset"] = reset_time
return allowed
@@ -10,7 +10,6 @@ from .issue import (
IssueAttachmentSerializer,
IssueActivitySerializer,
IssueExpandSerializer,
IssueLiteSerializer,
)
from .state import StateLiteSerializer, StateSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
-2
View File
@@ -67,7 +67,6 @@ class BaseSerializer(serializers.ModelSerializer):
# Import all the expandable serializers
from . import (
IssueSerializer,
IssueLiteSerializer,
ProjectLiteSerializer,
StateLiteSerializer,
UserLiteSerializer,
@@ -87,7 +86,6 @@ class BaseSerializer(serializers.ModelSerializer):
"actor": UserLiteSerializer,
"owned_by": UserLiteSerializer,
"members": UserLiteSerializer,
"parent": IssueLiteSerializer,
}
# Check if field in expansion then expand the field
if expand in expansion:
+3 -4
View File
@@ -13,9 +13,9 @@ class CycleSerializer(BaseSerializer):
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
total_estimates = serializers.FloatField(read_only=True)
completed_estimates = serializers.FloatField(read_only=True)
started_estimates = serializers.FloatField(read_only=True)
total_estimates = serializers.IntegerField(read_only=True)
completed_estimates = serializers.IntegerField(read_only=True)
started_estimates = serializers.IntegerField(read_only=True)
def validate(self, data):
if (
@@ -40,7 +40,6 @@ class CycleSerializer(BaseSerializer):
"workspace",
"project",
"owned_by",
"deleted_at",
]
+13 -45
View File
@@ -1,3 +1,6 @@
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
# Django imports
from django.utils import timezone
from lxml import html
@@ -8,7 +11,6 @@ from rest_framework import serializers
# Module imports
from plane.db.models import (
Issue,
IssueType,
IssueActivity,
IssueAssignee,
IssueAttachment,
@@ -27,9 +29,6 @@ from .module import ModuleLiteSerializer, ModuleSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
class IssueSerializer(BaseSerializer):
assignees = serializers.ListField(
@@ -47,12 +46,6 @@ class IssueSerializer(BaseSerializer):
write_only=True,
required=False,
)
type_id = serializers.PrimaryKeyRelatedField(
source="type",
queryset=IssueType.objects.all(),
required=False,
allow_null=True,
)
class Meta:
model = Issue
@@ -60,7 +53,9 @@ class IssueSerializer(BaseSerializer):
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
exclude = [
@@ -136,20 +131,7 @@ class IssueSerializer(BaseSerializer):
workspace_id = self.context["workspace_id"]
default_assignee_id = self.context["default_assignee_id"]
issue_type = validated_data.pop("type", None)
if not issue_type:
# Get default issue type
issue_type = IssueType.objects.filter(
project_issue_types__project_id=project_id, is_default=True
).first()
issue_type = issue_type
issue = Issue.objects.create(
**validated_data,
project_id=project_id,
type=issue_type,
)
issue = Issue.objects.create(**validated_data, project_id=project_id)
# Issue Audit Users
created_by_id = issue.created_by_id
@@ -274,17 +256,6 @@ class IssueSerializer(BaseSerializer):
return data
class IssueLiteSerializer(BaseSerializer):
class Meta:
model = Issue
fields = [
"id",
"sequence_id",
"project_id",
]
read_only_fields = fields
class LabelSerializer(BaseSerializer):
class Meta:
model = Label
@@ -297,7 +268,6 @@ class LabelSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
"deleted_at",
]
@@ -315,7 +285,7 @@ class IssueLinkSerializer(BaseSerializer):
"created_at",
"updated_at",
]
def validate_url(self, value):
# Check URL format
validate_url = URLValidator()
@@ -342,14 +312,10 @@ class IssueLinkSerializer(BaseSerializer):
return IssueLink.objects.create(**validated_data)
def update(self, instance, validated_data):
if (
IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
)
.exclude(pk=instance.id)
.exists()
):
if IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
).exclude(pk=instance.id).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
@@ -366,7 +332,9 @@ class IssueAttachmentSerializer(BaseSerializer):
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
-24
View File
@@ -39,7 +39,6 @@ class ModuleSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
"deleted_at",
]
def to_representation(self, instance):
@@ -71,16 +70,6 @@ class ModuleSerializer(BaseSerializer):
project_id = self.context["project_id"]
workspace_id = self.context["workspace_id"]
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if Module.objects.filter(
name=module_name, project_id=project_id
).exists():
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
module = Module.objects.create(**validated_data, project_id=project_id)
if members is not None:
ModuleMember.objects.bulk_create(
@@ -103,19 +92,6 @@ class ModuleSerializer(BaseSerializer):
def update(self, instance, validated_data):
members = validated_data.pop("members", None)
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if (
Module.objects.filter(
name=module_name, project=instance.project
)
.exclude(id=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
if members is not None:
ModuleMember.objects.filter(module=instance).delete()
@@ -31,7 +31,6 @@ class ProjectSerializer(BaseSerializer):
"updated_at",
"created_by",
"updated_by",
"deleted_at",
]
def validate(self, data):
-1
View File
@@ -23,7 +23,6 @@ class StateSerializer(BaseSerializer):
"updated_at",
"workspace",
"project",
"deleted_at",
]
-2
View File
@@ -4,7 +4,6 @@ from .issue import urlpatterns as issue_patterns
from .cycle import urlpatterns as cycle_patterns
from .module import urlpatterns as module_patterns
from .inbox import urlpatterns as inbox_patterns
from .member import urlpatterns as member_patterns
urlpatterns = [
*project_patterns,
@@ -13,5 +12,4 @@ urlpatterns = [
*cycle_patterns,
*module_patterns,
*inbox_patterns,
*member_patterns,
]
-6
View File
@@ -7,7 +7,6 @@ from plane.api.views import (
IssueCommentAPIEndpoint,
IssueActivityAPIEndpoint,
WorkspaceIssueAPIEndpoint,
IssueAttachmentEndpoint,
)
urlpatterns = [
@@ -66,9 +65,4 @@ urlpatterns = [
IssueActivityAPIEndpoint.as_view(),
name="activity",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
IssueAttachmentEndpoint.as_view(),
name="attachment",
),
]
-13
View File
@@ -1,13 +0,0 @@
from django.urls import path
from plane.api.views import (
ProjectMemberAPIEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<str:project_id>/members/",
ProjectMemberAPIEndpoint.as_view(),
name="users",
),
]
-4
View File
@@ -9,7 +9,6 @@ from .issue import (
IssueLinkAPIEndpoint,
IssueCommentAPIEndpoint,
IssueActivityAPIEndpoint,
IssueAttachmentEndpoint,
)
from .cycle import (
@@ -25,7 +24,4 @@ from .module import (
ModuleArchiveUnarchiveAPIEndpoint,
)
from .member import ProjectMemberAPIEndpoint
from .inbox import InboxIssueAPIEndpoint
+6 -21
View File
@@ -7,7 +7,6 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
from django.urls import resolve
from django.utils import timezone
from plane.db.models.api import APIToken
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@@ -17,7 +16,7 @@ from rest_framework.views import APIView
# Module imports
from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle
from plane.api.rate_limit import ApiKeyRateThrottle
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
@@ -45,29 +44,15 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
IsAuthenticated,
]
throttle_classes = [
ApiKeyRateThrottle,
]
def filter_queryset(self, queryset):
for backend in list(self.filter_backends):
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset
def get_throttles(self):
throttle_classes = []
api_key = self.request.headers.get("X-Api-Key")
if api_key:
service_token = APIToken.objects.filter(
token=api_key,
is_service=True,
).first()
if service_token:
throttle_classes.append(ServiceTokenRateThrottle())
return throttle_classes
throttle_classes.append(ApiKeyRateThrottle())
return throttle_classes
def handle_exception(self, exc):
"""
Handle any exception that occurs, by returning an appropriate response,
@@ -167,4 +152,4 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
for expand in self.request.GET.get("expand", "").split(",")
if expand
]
return expand if expand else None
return expand if expand else None
+64 -113
View File
@@ -26,7 +26,7 @@ from plane.api.serializers import (
CycleSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Cycle,
CycleIssue,
@@ -34,8 +34,6 @@ from plane.db.models import (
Project,
IssueAttachment,
IssueLink,
ProjectMember,
UserFavorite,
)
from plane.utils.analytics_plot import burndown_plot
@@ -365,28 +363,14 @@ class CycleAPIEndpoint(BaseAPIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if cycle.owned_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the cycle"},
status=status.HTTP_403_FORBIDDEN,
)
cycle_issues = list(
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
)
issue_activity.delay(
type="cycle.activity.deleted",
@@ -405,20 +389,11 @@ class CycleAPIEndpoint(BaseAPIView):
)
# Delete the cycle
cycle.delete()
# Delete the cycle issues
CycleIssue.objects.filter(
cycle_id=self.kwargs.get("pk"),
).delete()
# Delete the user favorite cycle
UserFavorite.objects.filter(
entity_type="cycle",
entity_identifier=pk,
project_id=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@@ -544,12 +519,6 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
)
cycle.archived_at = timezone.now()
cycle.save()
UserFavorite.objects.filter(
entity_type="cycle",
entity_identifier=cycle_id,
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, project_id, cycle_id):
@@ -678,63 +647,72 @@ class CycleIssueAPIEndpoint(BaseAPIView):
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
# Get all CycleIssues already created
cycle_issues = list(
CycleIssue.objects.filter(
~Q(cycle_id=cycle_id), issue_id__in=issues
)
)
existing_issues = [
str(cycle_issue.issue_id)
for cycle_issue in cycle_issues
if str(cycle_issue.issue_id) in issues
]
new_issues = list(set(issues) - set(existing_issues))
# New issues to create
created_records = CycleIssue.objects.bulk_create(
[
CycleIssue(
project_id=project_id,
workspace_id=cycle.workspace_id,
cycle_id=cycle_id,
issue_id=issue,
)
for issue in new_issues
],
ignore_conflicts=True,
batch_size=10,
)
# Updated Issues
updated_records = []
update_cycle_issue_activity = []
# Iterate over each cycle_issue in cycle_issues
for cycle_issue in cycle_issues:
old_cycle_id = cycle_issue.cycle_id
# Update the cycle_issue's cycle_id
cycle_issue.cycle_id = cycle_id
# Add the modified cycle_issue to the records_to_update list
updated_records.append(cycle_issue)
# Record the update activity
update_cycle_issue_activity.append(
if (
cycle.end_date is not None
and cycle.end_date < timezone.now().date()
):
return Response(
{
"old_cycle_id": str(old_cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
"error": "The Cycle has already been completed so no new issues can be added"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Update the cycle issues
issues = Issue.objects.filter(
pk__in=issues, workspace__slug=slug, project_id=project_id
).values_list("id", flat=True)
# Get all CycleIssues already created
cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues))
update_cycle_issue_activity = []
record_to_create = []
records_to_update = []
for issue in issues:
cycle_issue = [
cycle_issue
for cycle_issue in cycle_issues
if str(cycle_issue.issue_id) in issues
]
# Update only when cycle changes
if len(cycle_issue):
if cycle_issue[0].cycle_id != cycle_id:
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_issue[0].cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue[0].issue_id),
}
)
cycle_issue[0].cycle_id = cycle_id
records_to_update.append(cycle_issue[0])
else:
record_to_create.append(
CycleIssue(
project_id=project_id,
workspace=cycle.workspace,
created_by=request.user,
updated_by=request.user,
cycle=cycle,
issue_id=issue,
)
)
CycleIssue.objects.bulk_create(
record_to_create,
batch_size=10,
ignore_conflicts=True,
)
CycleIssue.objects.bulk_update(
updated_records, ["cycle_id"], batch_size=100
records_to_update,
["cycle"],
batch_size=10,
)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": issues}),
requested_data=json.dumps({"cycles_list": str(issues)}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
@@ -742,14 +720,13 @@ class CycleIssueAPIEndpoint(BaseAPIView):
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize(
"json", created_records
"json", record_to_create
),
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# Return all Cycle Issues
return Response(
CycleIssueSerializer(self.get_queryset(), many=True).data,
@@ -1163,38 +1140,12 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
)
updated_cycles = []
update_cycle_issue_activity = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_id),
"new_cycle_id": str(new_cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
cycle_issues = CycleIssue.objects.bulk_update(
updated_cycles, ["cycle_id"], batch_size=100
)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": []}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": "[]",
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
+30 -44
View File
@@ -1,13 +1,10 @@
# Python imports
import json
# Django imports
# Django improts
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Q
from django.utils import timezone
from django.db.models import Q, Value, UUIDField
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
# Third party imports
from rest_framework import status
@@ -16,7 +13,7 @@ from rest_framework.response import Response
# Module imports
from plane.api.serializers import InboxIssueSerializer, IssueSerializer
from plane.app.permissions import ProjectLitePermission
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Inbox,
InboxIssue,
@@ -184,8 +181,13 @@ class InboxIssueAPIEndpoint(BaseAPIView):
workspace__slug=slug, project_id=project_id
).first()
project = Project.objects.get(
workspace__slug=slug,
pk=project_id,
)
# Inbox view
if inbox is None:
if inbox is None and not project.inbox_view:
return Response(
{
"error": "Inbox is not enabled for this project enable it through the project's api"
@@ -222,27 +224,8 @@ class InboxIssueAPIEndpoint(BaseAPIView):
issue_data = request.data.pop("issue", False)
if bool(issue_data):
issue = Issue.objects.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
).get(
pk=issue_id,
workspace__slug=slug,
project_id=project_id,
issue = Issue.objects.get(
pk=issue_id, workspace__slug=slug, project_id=project_id
)
# Only allow guests and viewers to edit name and description
if project_member.role <= 10:
@@ -385,26 +368,29 @@ class InboxIssueAPIEndpoint(BaseAPIView):
inbox_id=inbox.id,
)
# Get the project member
project_member = ProjectMember.objects.get(
workspace__slug=slug,
project_id=project_id,
member=request.user,
is_active=True,
)
# Check the inbox issue created
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
request.user.id
):
return Response(
{"error": "You cannot delete inbox issue"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check the issue status
if inbox_issue.status in [-2, -1, 0, 2]:
# Delete the issue also
issue = Issue.objects.filter(
Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk=issue_id
).first()
if issue.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the issue"},
status=status.HTTP_403_FORBIDDEN,
)
issue.delete()
).delete()
inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+3 -264
View File
@@ -22,11 +22,9 @@ from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser
# Module imports
from plane.api.serializers import (
IssueAttachmentSerializer,
IssueActivitySerializer,
IssueCommentSerializer,
IssueLinkSerializer,
@@ -38,7 +36,7 @@ from plane.app.permissions import (
ProjectLitePermission,
ProjectMemberPermission,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueActivity,
@@ -151,25 +149,6 @@ class IssueAPIEndpoint(BaseAPIView):
).distinct()
def get(self, request, slug, project_id, pk=None):
external_id = request.GET.get("external_id")
external_source = request.GET.get("external_source")
if external_id and external_source:
issue = Issue.objects.get(
external_id=external_id,
external_source=external_source,
workspace__slug=slug,
project_id=project_id,
)
return Response(
IssueSerializer(
issue,
fields=self.fields,
expand=self.expand,
).data,
status=status.HTTP_200_OK,
)
if pk:
issue = Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(
@@ -328,17 +307,6 @@ class IssueAPIEndpoint(BaseAPIView):
)
serializer.save()
# Refetch the issue
issue = Issue.objects.filter(
workspace__slug=slug,
project_id=project_id,
pk=serializer.data["id"],
).first()
issue.created_at = request.data.get("created_at", timezone.now())
issue.created_by_id = request.data.get(
"created_by", request.user.id
)
issue.save(update_fields=["created_at", "created_by"])
# Track the issue
issue_activity.delay(
@@ -355,124 +323,6 @@ class IssueAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def put(self, request, slug, project_id):
# Get the entities required for putting the issue, external_id and
# external_source are must to identify the issue here
project = Project.objects.get(pk=project_id)
external_id = request.data.get("external_id")
external_source = request.data.get("external_source")
# If the external_id and source are present, we need to find the exact
# issue that needs to be updated with the provided external_id and
# external_source
if external_id and external_source:
try:
issue = Issue.objects.get(
project_id=project_id,
workspace__slug=slug,
external_id=external_id,
external_source=external_source,
)
# Get the current instance of the issue in order to track
# changes and dispatch the issue activity
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
# Get the requested data, encode it as django object and pass it
# to serializer to validation
requested_data = json.dumps(
self.request.data, cls=DjangoJSONEncoder
)
serializer = IssueSerializer(
issue,
data=request.data,
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
},
partial=True,
)
if serializer.is_valid():
# If the serializer is valid, save the issue and dispatch
# the update issue activity worker event.
serializer.save()
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
# If the serializer is not valid, respond with 400 bad
# request
serializer.errors,
status=status.HTTP_400_BAD_REQUEST,
)
except Issue.DoesNotExist:
# If the issue does not exist, a new record needs to be created
# for the requested data.
# Serialize the data with the context of the project and
# workspace
serializer = IssueSerializer(
data=request.data,
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
},
)
# If the serializer is valid, save the issue and dispatch the
# issue activity worker event as created
if serializer.is_valid():
serializer.save()
# Refetch the issue
issue = Issue.objects.filter(
workspace__slug=slug,
project_id=project_id,
pk=serializer.data["id"],
).first()
# If any of the created_at or created_by is present, update
# the issue with the provided data, else return with the
# default states given.
issue.created_at = request.data.get(
"created_at", timezone.now()
)
issue.created_by_id = request.data.get(
"created_by", request.user.id
)
issue.save(update_fields=["created_at", "created_by"])
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(
self.request.data, cls=DjangoJSONEncoder
),
actor_id=str(request.user.id),
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(
serializer.data, status=status.HTTP_201_CREATED
)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
else:
return Response(
{"error": "external_id and external_source are required"},
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
@@ -529,19 +379,6 @@ class IssueAPIEndpoint(BaseAPIView):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if issue.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the issue"},
status=status.HTTP_403_FORBIDDEN,
)
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
@@ -750,20 +587,14 @@ class IssueLinkAPIEndpoint(BaseAPIView):
project_id=project_id,
issue_id=issue_id,
)
link = IssueLink.objects.get(pk=serializer.data["id"])
link.created_by_id = request.data.get(
"created_by", request.user.id
)
link.save(update_fields=["created_by"])
issue_activity.delay(
type="link.activity.created",
requested_data=json.dumps(
serializer.data, cls=DjangoJSONEncoder
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
actor_id=str(link.created_by_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
@@ -917,24 +748,12 @@ class IssueCommentAPIEndpoint(BaseAPIView):
issue_id=issue_id,
actor=request.user,
)
issue_comment = IssueComment.objects.get(
pk=serializer.data.get("id")
)
# Update the created_at and the created_by and save the comment
issue_comment.created_at = request.data.get(
"created_at", timezone.now()
)
issue_comment.created_by_id = request.data.get(
"created_by", request.user.id
)
issue_comment.save(update_fields=["created_at", "created_by"])
issue_activity.delay(
type="comment.activity.created",
requested_data=json.dumps(
serializer.data, cls=DjangoJSONEncoder
),
actor_id=str(issue_comment.created_by_id),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
@@ -1055,83 +874,3 @@ class IssueActivityAPIEndpoint(BaseAPIView):
expand=self.expand,
).data,
)
class IssueAttachmentEndpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer
permission_classes = [
ProjectEntityPermission,
]
model = IssueAttachment
parser_classes = (MultiPartParser, FormParser)
def post(self, request, slug, project_id, issue_id):
serializer = IssueAttachmentSerializer(data=request.data)
if (
request.data.get("external_id")
and request.data.get("external_source")
and IssueAttachment.objects.filter(
project_id=project_id,
workspace__slug=slug,
issue_id=issue_id,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).exists()
):
issue_attachment = IssueAttachment.objects.filter(
workspace__slug=slug,
project_id=project_id,
external_id=request.data.get("external_id"),
external_source=request.data.get("external_source"),
).first()
return Response(
{
"error": "Issue attachment with the same external id and external source already exists",
"id": str(issue_attachment.id),
},
status=status.HTTP_409_CONFLICT,
)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
issue_activity.delay(
type="attachment.activity.created",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
serializer.data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment = IssueAttachment.objects.get(pk=pk)
issue_attachment.asset.delete(save=False)
issue_attachment.delete()
issue_activity.delay(
type="attachment.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
def get(self, request, slug, project_id, issue_id):
issue_attachments = IssueAttachment.objects.filter(
issue_id=issue_id, workspace__slug=slug, project_id=project_id
)
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
-153
View File
@@ -1,153 +0,0 @@
# Python imports
import uuid
# Django imports
from django.contrib.auth.hashers import make_password
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .base import BaseAPIView
from plane.api.serializers import UserLiteSerializer
from plane.db.models import (
User,
Workspace,
Project,
WorkspaceMember,
ProjectMember,
)
from plane.app.permissions import (
ProjectMemberPermission,
)
# API endpoint to get and insert users inside the workspace
class ProjectMemberAPIEndpoint(BaseAPIView):
permission_classes = [
ProjectMemberPermission,
]
# Get all the users that are present inside the workspace
def get(self, request, slug, project_id):
# Check if the workspace exists
if not Workspace.objects.filter(slug=slug).exists():
return Response(
{"error": "Provided workspace does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the workspace members that are present inside the workspace
project_members = ProjectMember.objects.filter(
project_id=project_id, workspace__slug=slug
).values_list("member_id", flat=True)
# Get all the users that are present inside the workspace
users = UserLiteSerializer(
User.objects.filter(
id__in=project_members,
),
many=True,
).data
return Response(users, status=status.HTTP_200_OK)
# Insert a new user inside the workspace, and assign the user to the project
def post(self, request, slug, project_id):
# Check if user with email already exists, and send bad request if it's
# not present, check for workspace and valid project mandat
# ------------------- Validation -------------------
if (
request.data.get("email") is None
or request.data.get("display_name") is None
):
return Response(
{
"error": "Expected email, display_name, workspace_slug, project_id, one or more of the fields are missing."
},
status=status.HTTP_400_BAD_REQUEST,
)
email = request.data.get("email")
try:
validate_email(email)
except ValidationError:
return Response(
{"error": "Invalid email provided"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.filter(slug=slug).first()
project = Project.objects.filter(pk=project_id).first()
if not all([workspace, project]):
return Response(
{"error": "Provided workspace or project does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if user exists
user = User.objects.filter(email=email).first()
workspace_member = None
project_member = None
if user:
# Check if user is part of the workspace
workspace_member = WorkspaceMember.objects.filter(
workspace=workspace, member=user
).first()
if workspace_member:
# Check if user is part of the project
project_member = ProjectMember.objects.filter(
project=project, member=user
).first()
if project_member:
return Response(
{
"error": "User is already part of the workspace and project"
},
status=status.HTTP_400_BAD_REQUEST,
)
# If user does not exist, create the user
if not user:
user = User.objects.create(
email=email,
display_name=request.data.get("display_name"),
first_name=request.data.get("first_name", ""),
last_name=request.data.get("last_name", ""),
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
is_active=False,
)
user.save()
# Create a workspace member for the user if not already a member
if not workspace_member:
workspace_member = WorkspaceMember.objects.create(
workspace=workspace,
member=user,
role=request.data.get("role", 10),
)
workspace_member.save()
# Create a project member for the user if not already a member
if not project_member:
project_member = ProjectMember.objects.create(
project=project,
member=user,
role=request.data.get("role", 10),
)
project_member.save()
# Serialize the user and return the response
user_data = UserLiteSerializer(user).data
return Response(user_data, status=status.HTTP_201_CREATED)
+3 -39
View File
@@ -18,7 +18,7 @@ from plane.api.serializers import (
ModuleSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueAttachment,
@@ -27,8 +27,6 @@ from plane.db.models import (
ModuleIssue,
ModuleLink,
Project,
ProjectMember,
UserFavorite,
)
from .base import BaseAPIView
@@ -267,20 +265,6 @@ class ModuleAPIEndpoint(BaseAPIView):
module = Module.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if module.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the module"},
status=status.HTTP_403_FORBIDDEN,
)
module_issues = list(
ModuleIssue.objects.filter(module_id=pk).values_list(
"issue", flat=True
@@ -298,25 +282,10 @@ class ModuleAPIEndpoint(BaseAPIView):
actor_id=str(request.user.id),
issue_id=None,
project_id=str(project_id),
current_instance=json.dumps(
{
"module_name": str(module.name),
}
),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
module.delete()
# Delete the module issues
ModuleIssue.objects.filter(
module=pk,
project_id=project_id,
).delete()
# Delete the user favorite module
UserFavorite.objects.filter(
entity_type="module",
entity_identifier=pk,
project_id=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -524,6 +493,7 @@ class ModuleIssueAPIEndpoint(BaseAPIView):
class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@@ -638,12 +608,6 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
)
module.archived_at = timezone.now()
module.save()
UserFavorite.objects.filter(
entity_type="module",
entity_identifier=pk,
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, project_id, pk):
+3 -15
View File
@@ -19,14 +19,13 @@ from plane.app.permissions import ProjectBasePermission
from plane.db.models import (
Cycle,
Inbox,
IssueUserProperty,
IssueProperty,
Module,
Project,
DeployBoard,
ProjectMember,
State,
Workspace,
UserFavorite,
)
from plane.bgtasks.webhook_task import model_activity
from .base import BaseAPIView
@@ -166,7 +165,7 @@ class ProjectAPIEndpoint(BaseAPIView):
role=20,
)
# Also create the issue property for the user
_ = IssueUserProperty.objects.create(
_ = IssueProperty.objects.create(
project_id=serializer.data["id"],
user=request.user,
)
@@ -180,7 +179,7 @@ class ProjectAPIEndpoint(BaseAPIView):
role=20,
)
# Also create the issue property for the user
IssueUserProperty.objects.create(
IssueProperty.objects.create(
project_id=serializer.data["id"],
user_id=serializer.data["project_lead"],
)
@@ -241,7 +240,6 @@ class ProjectAPIEndpoint(BaseAPIView):
.filter(pk=serializer.data["id"])
.first()
)
# Model activity
model_activity.delay(
model_name="project",
@@ -357,12 +355,6 @@ class ProjectAPIEndpoint(BaseAPIView):
def delete(self, request, slug, pk):
project = Project.objects.get(pk=pk, workspace__slug=slug)
# Delete the user favorite cycle
UserFavorite.objects.filter(
entity_type="project",
entity_identifier=pk,
project_id=pk,
).delete()
project.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -377,10 +369,6 @@ class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = timezone.now()
project.save()
UserFavorite.objects.filter(
workspace__slug=slug,
project=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, project_id):
@@ -12,4 +12,3 @@ from .project import (
ProjectMemberPermission,
ProjectLitePermission,
)
from .base import allow_permission, ROLE
-61
View File
@@ -1,61 +0,0 @@
from plane.db.models import WorkspaceMember, ProjectMember
from functools import wraps
from rest_framework.response import Response
from rest_framework import status
from enum import Enum
class ROLE(Enum):
ADMIN = 20
MEMBER = 15
VIEWER = 10
GUEST = 5
def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
def decorator(view_func):
@wraps(view_func)
def _wrapped_view(instance, request, *args, **kwargs):
# Check for creator if required
if creator and model:
obj = model.objects.filter(
id=kwargs["pk"], created_by=request.user
).exists()
if obj:
return view_func(instance, request, *args, **kwargs)
# Convert allowed_roles to their values if they are enum members
allowed_role_values = [
role.value if isinstance(role, ROLE) else role
for role in allowed_roles
]
# Check role permissions
if level == "WORKSPACE":
if WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=kwargs["slug"],
role__in=allowed_role_values,
is_active=True,
).exists():
return view_func(instance, request, *args, **kwargs)
else:
if ProjectMember.objects.filter(
member=request.user,
workspace__slug=kwargs["slug"],
project_id=kwargs["project_id"],
role__in=allowed_role_values,
is_active=True,
).exists():
return view_func(instance, request, *args, **kwargs)
# Return permission denied if no conditions are met
return Response(
{"error": "You don't have the required permissions."},
status=status.HTTP_403_FORBIDDEN,
)
return _wrapped_view
return decorator
+1 -5
View File
@@ -50,7 +50,7 @@ from .issue import (
IssueCreateSerializer,
IssueActivitySerializer,
IssueCommentSerializer,
IssueUserPropertySerializer,
IssuePropertySerializer,
IssueAssigneeSerializer,
LabelSerializer,
IssueSerializer,
@@ -91,8 +91,6 @@ from .page import (
PageLogSerializer,
SubPageSerializer,
PageDetailSerializer,
PageVersionSerializer,
PageVersionDetailSerializer,
)
from .estimate import (
@@ -122,5 +120,3 @@ from .exporter import ExporterHistorySerializer
from .webhook import WebhookSerializer, WebhookLogSerializer
from .dashboard import DashboardSerializer, WidgetSerializer
from .favorite import UserFavoriteSerializer
-101
View File
@@ -1,101 +0,0 @@
from rest_framework import serializers
from plane.db.models import (
UserFavorite,
Cycle,
Module,
Issue,
IssueView,
Page,
Project,
)
class ProjectFavoriteLiteSerializer(serializers.ModelSerializer):
class Meta:
model = Project
fields = ["id", "name", "logo_props"]
class PageFavoriteLiteSerializer(serializers.ModelSerializer):
project_id = serializers.SerializerMethodField()
class Meta:
model = Page
fields = ["id", "name", "logo_props", "project_id"]
def get_project_id(self, obj):
project = (
obj.projects.first()
) # This gets the first project related to the Page
return project.id if project else None
class CycleFavoriteLiteSerializer(serializers.ModelSerializer):
class Meta:
model = Cycle
fields = ["id", "name", "logo_props", "project_id"]
class ModuleFavoriteLiteSerializer(serializers.ModelSerializer):
class Meta:
model = Module
fields = ["id", "name", "logo_props", "project_id"]
class ViewFavoriteSerializer(serializers.ModelSerializer):
class Meta:
model = IssueView
fields = ["id", "name", "logo_props", "project_id"]
def get_entity_model_and_serializer(entity_type):
entity_map = {
"cycle": (Cycle, CycleFavoriteLiteSerializer),
"issue": (Issue, None),
"module": (Module, ModuleFavoriteLiteSerializer),
"view": (IssueView, ViewFavoriteSerializer),
"page": (Page, PageFavoriteLiteSerializer),
"project": (Project, ProjectFavoriteLiteSerializer),
"folder": (None, None),
}
return entity_map.get(entity_type, (None, None))
class UserFavoriteSerializer(serializers.ModelSerializer):
entity_data = serializers.SerializerMethodField()
class Meta:
model = UserFavorite
fields = [
"id",
"entity_type",
"entity_identifier",
"entity_data",
"name",
"is_folder",
"sequence",
"parent",
"workspace_id",
"project_id",
]
read_only_fields = ["workspace", "created_by", "updated_by"]
def get_entity_data(self, obj):
entity_type = obj.entity_type
entity_identifier = obj.entity_identifier
entity_model, entity_serializer = get_entity_model_and_serializer(
entity_type
)
if entity_model and entity_serializer:
try:
entity = entity_model.objects.get(pk=entity_identifier)
return entity_serializer(entity).data
except entity_model.DoesNotExist:
return None
return None
+18 -37
View File
@@ -17,7 +17,7 @@ from plane.db.models import (
Issue,
IssueActivity,
IssueComment,
IssueUserProperty,
IssueProperty,
IssueAssignee,
IssueSubscriber,
IssueLabel,
@@ -135,11 +135,7 @@ class IssueCreateSerializer(BaseSerializer):
workspace_id = self.context["workspace_id"]
default_assignee_id = self.context["default_assignee_id"]
# Create Issue
issue = Issue.objects.create(
**validated_data,
project_id=project_id,
)
issue = Issue.objects.create(**validated_data, project_id=project_id)
# Issue Audit Users
created_by_id = issue.created_by_id
@@ -252,9 +248,9 @@ class IssueActivitySerializer(BaseSerializer):
fields = "__all__"
class IssueUserPropertySerializer(BaseSerializer):
class IssuePropertySerializer(BaseSerializer):
class Meta:
model = IssueUserProperty
model = IssueProperty
fields = "__all__"
read_only_fields = [
"user",
@@ -437,21 +433,17 @@ class IssueLinkSerializer(BaseSerializer):
"issue",
]
def to_internal_value(self, data):
# Modify the URL before validation by appending http:// if missing
url = data.get("url", "")
if url and not url.startswith(("http://", "https://")):
data["url"] = "http://" + url
return super().to_internal_value(data)
def validate_url(self, value):
# Use Django's built-in URLValidator for validation
url_validator = URLValidator()
# Check URL format
validate_url = URLValidator()
try:
url_validator(value)
validate_url(value)
except ValidationError:
raise serializers.ValidationError({"error": "Invalid URL format."})
raise serializers.ValidationError("Invalid URL format.")
# Check URL scheme
if not value.startswith(("http://", "https://")):
raise serializers.ValidationError("Invalid URL scheme.")
return value
@@ -467,14 +459,10 @@ class IssueLinkSerializer(BaseSerializer):
return IssueLink.objects.create(**validated_data)
def update(self, instance, validated_data):
if (
IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
)
.exclude(pk=instance.id)
.exists()
):
if IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
).exclude(pk=instance.id).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
@@ -521,7 +509,7 @@ class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
"attributes",
"issue_id",
"updated_at",
"updated_by",
"updated_by_id",
]
read_only_fields = fields
@@ -537,7 +525,6 @@ class IssueReactionSerializer(BaseSerializer):
"project",
"issue",
"actor",
"deleted_at",
]
@@ -556,13 +543,7 @@ class CommentReactionSerializer(BaseSerializer):
class Meta:
model = CommentReaction
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"comment",
"actor",
"deleted_at",
]
read_only_fields = ["workspace", "project", "comment", "actor"]
class IssueVoteSerializer(BaseSerializer):
+3 -70
View File
@@ -5,10 +5,6 @@ from rest_framework import serializers
from .base import BaseSerializer, DynamicBaseSerializer
from .project import ProjectLiteSerializer
# Django imports
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from plane.db.models import (
User,
Module,
@@ -43,7 +39,6 @@ class ModuleWriteSerializer(BaseSerializer):
"created_at",
"updated_at",
"archived_at",
"deleted_at",
]
def to_representation(self, instance):
@@ -68,16 +63,6 @@ class ModuleWriteSerializer(BaseSerializer):
members = validated_data.pop("member_ids", None)
project = self.context["project"]
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if Module.objects.filter(
name=module_name, project=project
).exists():
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
module = Module.objects.create(**validated_data, project=project)
if members is not None:
ModuleMember.objects.bulk_create(
@@ -100,19 +85,6 @@ class ModuleWriteSerializer(BaseSerializer):
def update(self, instance, validated_data):
members = validated_data.pop("member_ids", None)
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if (
Module.objects.filter(
name=module_name, project=instance.project
)
.exclude(id=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
if members is not None:
ModuleMember.objects.filter(module=instance).delete()
@@ -182,48 +154,16 @@ class ModuleLinkSerializer(BaseSerializer):
"module",
]
def to_internal_value(self, data):
# Modify the URL before validation by appending http:// if missing
url = data.get("url", "")
if url and not url.startswith(("http://", "https://")):
data["url"] = "http://" + url
return super().to_internal_value(data)
def validate_url(self, value):
# Use Django's built-in URLValidator for validation
url_validator = URLValidator()
try:
url_validator(value)
except ValidationError:
raise serializers.ValidationError({"error": "Invalid URL format."})
return value
# Validation if url already exists
def create(self, validated_data):
validated_data["url"] = self.validate_url(validated_data.get("url"))
if ModuleLink.objects.filter(
url=validated_data.get("url"),
module_id=validated_data.get("module_id"),
).exists():
raise serializers.ValidationError({"error": "URL already exists."})
return super().create(validated_data)
def update(self, instance, validated_data):
validated_data["url"] = self.validate_url(validated_data.get("url"))
if (
ModuleLink.objects.filter(
url=validated_data.get("url"),
module_id=instance.module_id,
)
.exclude(pk=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
return super().update(instance, validated_data)
return ModuleLink.objects.create(**validated_data)
class ModuleSerializer(DynamicBaseSerializer):
@@ -288,14 +228,7 @@ class ModuleDetailSerializer(ModuleSerializer):
cancelled_estimate_points = serializers.FloatField(read_only=True)
class Meta(ModuleSerializer.Meta):
fields = ModuleSerializer.Meta.fields + [
"link_module",
"sub_issues",
"backlog_estimate_points",
"unstarted_estimate_points",
"started_estimate_points",
"cancelled_estimate_points",
]
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues", "backlog_estimate_points", "unstarted_estimate_points", "started_estimate_points", "cancelled_estimate_points"]
class ModuleUserPropertiesSerializer(BaseSerializer):
@@ -12,7 +12,6 @@ class NotificationSerializer(BaseSerializer):
read_only=True, source="triggered_by"
)
is_inbox_issue = serializers.BooleanField(read_only=True)
is_mentioned_notification = serializers.BooleanField(read_only=True)
class Meta:
model = Notification
-44
View File
@@ -10,7 +10,6 @@ from plane.db.models import (
Label,
ProjectPage,
Project,
PageVersion,
)
@@ -162,46 +161,3 @@ class PageLogSerializer(BaseSerializer):
"workspace",
"page",
]
class PageVersionSerializer(BaseSerializer):
class Meta:
model = PageVersion
fields = [
"id",
"workspace",
"page",
"last_saved_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = [
"workspace",
"page",
]
class PageVersionDetailSerializer(BaseSerializer):
class Meta:
model = PageVersion
fields = [
"id",
"workspace",
"page",
"last_saved_at",
"description_binary",
"description_html",
"description_json",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = [
"workspace",
"page",
]
@@ -28,7 +28,6 @@ class ProjectSerializer(BaseSerializer):
fields = "__all__"
read_only_fields = [
"workspace",
"deleted_at",
]
def create(self, validated_data):
+3 -22
View File
@@ -16,39 +16,26 @@ from .base import BaseSerializer
class UserSerializer(BaseSerializer):
class Meta:
model = User
# Exclude password field from the serializer
fields = [
field.name
for field in User._meta.fields
if field.name != "password"
]
# Make all system fields and email read only
fields = "__all__"
read_only_fields = [
"id",
"username",
"mobile_number",
"email",
"token",
"created_at",
"updated_at",
"is_superuser",
"is_staff",
"is_managed",
"last_active",
"last_login_time",
"last_logout_time",
"last_login_ip",
"last_logout_ip",
"last_login_uagent",
"last_location",
"last_login_medium",
"created_location",
"token_updated_at",
"is_bot",
"is_password_autoset",
"is_email_verified",
"is_active",
"token_updated_at",
]
extra_kwargs = {"password": {"write_only": True}}
# If the user has already filled first name or last name then he is onboarded
def get_is_onboarded(self, obj):
@@ -221,15 +208,9 @@ class ProfileSerializer(BaseSerializer):
class Meta:
model = Profile
fields = "__all__"
read_only_fields = [
"user",
]
class AccountSerializer(BaseSerializer):
class Meta:
model = Account
fields = "__all__"
read_only_fields = [
"user",
]
+2 -2
View File
@@ -23,7 +23,7 @@ class IssueViewSerializer(DynamicBaseSerializer):
]
def create(self, validated_data):
query_params = validated_data.get("filters", {})
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
@@ -31,7 +31,7 @@ class IssueViewSerializer(DynamicBaseSerializer):
return IssueView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("filters", {})
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
+2 -2
View File
@@ -40,7 +40,7 @@ class WebhookSerializer(DynamicBaseSerializer):
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_loopback:
if ip.is_private or ip.is_loopback:
raise serializers.ValidationError(
{"url": "URL resolves to a blocked IP address."}
)
@@ -92,7 +92,7 @@ class WebhookSerializer(DynamicBaseSerializer):
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_loopback:
if ip.is_private or ip.is_loopback:
raise serializers.ValidationError(
{"url": "URL resolves to a blocked IP address."}
)
+1 -6
View File
@@ -1,5 +1,5 @@
from django.urls import path
from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
from plane.app.views import ApiTokenEndpoint
urlpatterns = [
# API Tokens
@@ -13,10 +13,5 @@ urlpatterns = [
ApiTokenEndpoint.as_view(),
name="api-tokens",
),
path(
"workspaces/<str:slug>/service-api-tokens/",
ServiceApiTokenEndpoint.as_view(),
name="service-api-tokens",
),
## End API Tokens
]
-12
View File
@@ -6,8 +6,6 @@ from plane.app.views import (
CycleIssueViewSet,
CycleDateCheckEndpoint,
CycleFavoriteViewSet,
CycleProgressEndpoint,
CycleAnalyticsEndpoint,
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
CycleArchiveUnarchiveEndpoint,
@@ -108,14 +106,4 @@ urlpatterns = [
CycleArchiveUnarchiveEndpoint.as_view(),
name="cycle-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/progress/",
CycleProgressEndpoint.as_view(),
name="project-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/analytics/",
CycleAnalyticsEndpoint.as_view(),
name="project-cycle",
),
]
+1 -1
View File
@@ -40,7 +40,7 @@ urlpatterns = [
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:issue_id>/",
InboxIssueViewSet.as_view(
{
"get": "retrieve",
+8 -9
View File
@@ -19,8 +19,8 @@ from plane.app.views import (
IssueUserDisplayPropertyEndpoint,
IssueViewSet,
LabelViewSet,
BulkIssueOperationsEndpoint,
BulkArchiveIssuesEndpoint,
IssuePaginatedViewSet,
)
urlpatterns = [
@@ -39,12 +39,6 @@ urlpatterns = [
),
name="project-issue",
),
# updated v1 paginated issues
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/v2/issues/",
IssuePaginatedViewSet.as_view({"get": "list"}),
name="project-issues-paginated",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
IssueViewSet.as_view(
@@ -239,13 +233,13 @@ urlpatterns = [
name="project-issue-comment-reactions",
),
## End Comment Reactions
## IssueUserProperty
## IssueProperty
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-properties/",
IssueUserDisplayPropertyEndpoint.as_view(),
name="project-issue-display-properties",
),
## IssueUserProperty End
## IssueProperty End
## Issue Archives
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/",
@@ -311,4 +305,9 @@ urlpatterns = [
),
name="project-issue-draft",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-operation-issues/",
BulkIssueOperationsEndpoint.as_view(),
name="bulk-operations-issues",
),
]
-21
View File
@@ -7,7 +7,6 @@ from plane.app.views import (
PageLogEndpoint,
SubPagesEndpoint,
PagesDescriptionViewSet,
PageVersionEndpoint,
)
@@ -66,16 +65,6 @@ urlpatterns = [
),
name="project-pages-lock-unlock",
),
# private and public page
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/access/",
PageViewSet.as_view(
{
"post": "access",
}
),
name="project-pages-access",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/",
PageLogEndpoint.as_view(),
@@ -101,14 +90,4 @@ urlpatterns = [
),
name="page-description",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/versions/",
PageVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/versions/<uuid:pk>/",
PageVersionEndpoint.as_view(),
name="page-versions",
),
]
-17
View File
@@ -25,8 +25,6 @@ from plane.app.views import (
ExportWorkspaceUserActivityEndpoint,
WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint,
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
)
@@ -239,19 +237,4 @@ urlpatterns = [
WorkspaceCyclesEndpoint.as_view(),
name="workspace-cycles",
),
path(
"workspaces/<str:slug>/user-favorites/",
WorkspaceFavoriteEndpoint.as_view(),
name="workspace-user-favorites",
),
path(
"workspaces/<str:slug>/user-favorites/<uuid:favorite_id>/",
WorkspaceFavoriteEndpoint.as_view(),
name="workspace-user-favorites",
),
path(
"workspaces/<str:slug>/user-favorites/<uuid:favorite_id>/group/",
WorkspaceFavoriteGroupEndpoint.as_view(),
name="workspace-user-favorites-groups",
),
]
+5 -13
View File
@@ -40,11 +40,6 @@ from .workspace.base import (
ExportWorkspaceUserActivityEndpoint,
)
from .workspace.favorite import (
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
)
from .workspace.member import (
WorkSpaceMemberViewSet,
TeamMemberViewSet,
@@ -98,8 +93,6 @@ from .cycle.base import (
CycleUserPropertiesEndpoint,
CycleViewSet,
TransferCycleIssueEndpoint,
CycleAnalyticsEndpoint,
CycleProgressEndpoint,
)
from .cycle.issue import (
CycleIssueViewSet,
@@ -114,7 +107,6 @@ from .issue.base import (
IssueViewSet,
IssueUserDisplayPropertyEndpoint,
BulkDeleteIssuesEndpoint,
IssuePaginatedViewSet,
)
from .issue.activity import (
@@ -159,6 +151,9 @@ from .issue.subscriber import (
IssueSubscriberViewSet,
)
from .issue.bulk_operations import BulkIssueOperationsEndpoint
from .module.base import (
ModuleViewSet,
ModuleLinkViewSet,
@@ -174,10 +169,8 @@ from .module.archive import (
ModuleArchiveUnarchiveEndpoint,
)
from .api import (
ApiTokenEndpoint,
ServiceApiTokenEndpoint,
)
from .api import ApiTokenEndpoint
from .page.base import (
PageViewSet,
@@ -186,7 +179,6 @@ from .page.base import (
SubPagesEndpoint,
PagesDescriptionViewSet,
)
from .page.version import PageVersionEndpoint
from .search.base import GlobalSearchEndpoint
from .search.issue import IssueSearchEndpoint
+18 -16
View File
@@ -7,22 +7,22 @@ from django.utils import timezone
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import WorkSpaceAdminPermission
from plane.app.serializers import AnalyticViewSerializer
# Module imports
from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.bgtasks.analytic_plot_export import analytic_export_task
from plane.db.models import AnalyticView, Issue, Workspace
from plane.utils.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters
from plane.app.permissions import allow_permission, ROLE
class AnalyticsEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE"
)
def get(self, request, slug):
x_axis = request.GET.get("x_axis", False)
y_axis = request.GET.get("y_axis", False)
@@ -201,10 +201,10 @@ class AnalyticViewViewset(BaseViewSet):
class SavedAnalyticEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE"
)
def get(self, request, slug, analytic_id):
analytic_view = AnalyticView.objects.get(
pk=analytic_id, workspace__slug=slug
@@ -234,10 +234,10 @@ class SavedAnalyticEndpoint(BaseAPIView):
class ExportAnalyticsEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE"
)
def post(self, request, slug):
x_axis = request.data.get("x_axis", False)
y_axis = request.data.get("y_axis", False)
@@ -301,10 +301,10 @@ class ExportAnalyticsEndpoint(BaseAPIView):
class DefaultAnalyticsEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST], level="WORKSPACE"
)
def get(self, request, slug):
filters = issue_filters(request.GET, "GET")
base_issues = Issue.issue_objects.filter(
@@ -380,10 +380,12 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
.order_by("-count")
)
open_estimate_sum = open_issues_queryset.aggregate(sum=Sum("point"))[
open_estimate_sum = open_issues_queryset.aggregate(
sum=Sum("point")
)["sum"]
total_estimate_sum = base_issues.aggregate(sum=Sum("point"))[
"sum"
]
total_estimate_sum = base_issues.aggregate(sum=Sum("point"))["sum"]
return Response(
{
+1 -43
View File
@@ -45,7 +45,7 @@ class ApiTokenEndpoint(BaseAPIView):
def get(self, request, slug, pk=None):
if pk is None:
api_tokens = APIToken.objects.filter(
user=request.user, workspace__slug=slug, is_service=False
user=request.user, workspace__slug=slug
)
serializer = APITokenReadSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -61,7 +61,6 @@ class ApiTokenEndpoint(BaseAPIView):
workspace__slug=slug,
user=request.user,
pk=pk,
is_service=False,
)
api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -79,44 +78,3 @@ class ApiTokenEndpoint(BaseAPIView):
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [
WorkspaceOwnerPermission,
]
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
api_token = APIToken.objects.filter(
workspace=workspace,
is_service=True,
).first()
if api_token:
return Response(
{
"token": str(api_token.token),
},
status=status.HTTP_200_OK,
)
else:
# Check the user type
user_type = 1 if request.user.is_bot else 0
api_token = APIToken.objects.create(
label=str(uuid4().hex),
description="Service Token",
user=request.user,
workspace=workspace,
user_type=user_type,
is_service=True,
)
return Response(
{
"token": str(api_token.token),
},
status=status.HTTP_201_CREATED,
)
+30 -247
View File
@@ -14,18 +14,21 @@ from django.db.models import (
UUIDField,
Value,
When,
Subquery,
Sum,
FloatField,
)
from django.db.models.functions import Coalesce, Cast
from django.db.models.functions import Coalesce
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import Cycle, UserFavorite, Issue, Label, User, Project
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
Cycle,
UserFavorite,
Issue,
Label,
User,
)
from plane.utils.analytics_plot import burndown_plot
# Module imports
@@ -34,6 +37,10 @@ from .. import BaseAPIView
class CycleArchiveUnarchiveEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
favorite_subquery = UserFavorite.objects.filter(
user=self.request.user,
@@ -42,89 +49,6 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
backlog_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="backlog",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
backlog_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("backlog_estimate_point")[:1]
)
unstarted_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="unstarted",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
unstarted_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("unstarted_estimate_point")[:1]
)
started_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="started",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
started_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("started_estimate_point")[:1]
)
cancelled_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="cancelled",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
cancelled_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("cancelled_estimate_point")[:1]
)
completed_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="completed",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
completed_estimate_points=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("completed_estimate_points")[:1]
)
total_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
total_estimate_points=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("total_estimate_points")[:1]
)
return (
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
@@ -248,51 +172,24 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
Value([], output_field=ArrayField(UUIDField())),
)
)
.annotate(
backlog_estimate_points=Coalesce(
Subquery(backlog_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
unstarted_estimate_points=Coalesce(
Subquery(unstarted_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
started_estimate_points=Coalesce(
Subquery(started_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
cancelled_estimate_points=Coalesce(
Subquery(cancelled_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
completed_estimate_points=Coalesce(
Subquery(completed_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
total_estimate_points=Coalesce(
Subquery(total_estimate_point),
Value(0, output_field=FloatField()),
),
)
.order_by("-is_favorite", "name")
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def get(self, request, slug, project_id, pk=None):
if pk is None:
queryset = (
self.get_queryset().values(
self.get_queryset()
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.values(
# necessary fields
"id",
"workspace_id",
@@ -358,10 +255,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
"external_id",
"progress_snapshot",
"sub_issues",
"logo_props",
# meta fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite",
"total_issues",
"cancelled_issues",
@@ -371,114 +265,17 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
"backlog_issues",
"assignee_ids",
"status",
"created_by",
"archived_at",
)
.first()
)
queryset = queryset.first()
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
data["estimate_distribution"] = {}
if estimate_type:
assignee_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("display_name", "assignee_id", "avatar")
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", FloatField())
)
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
if data is None:
return Response(
{"error": "Cycle does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
label_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=pk,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", FloatField())
)
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
data["estimate_distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
if data["start_date"] and data["end_date"]:
data["estimate_distribution"]["completion_chart"] = (
burndown_plot(
queryset=queryset,
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=pk,
)
)
# Assignee Distribution
assignee_distribution = (
Issue.issue_objects.filter(
@@ -501,10 +298,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
.annotate(
total_issues=Count(
"id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
@@ -544,10 +338,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
.annotate(
total_issues=Count(
"id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
),
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
@@ -593,7 +384,6 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
@@ -607,18 +397,11 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
cycle.archived_at = timezone.now()
cycle.save()
UserFavorite.objects.filter(
entity_type="cycle",
entity_identifier=cycle_id,
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(
{"archived_at": str(cycle.archived_at)},
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def delete(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
File diff suppressed because it is too large Load Diff
+16 -7
View File
@@ -3,7 +3,12 @@ import json
# Django imports
from django.core import serializers
from django.db.models import F, Func, OuterRef, Q
from django.db.models import (
F,
Func,
OuterRef,
Q,
)
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
@@ -12,12 +17,15 @@ from django.views.decorators.gzip import gzip_page
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
ProjectEntityPermission,
)
# Module imports
from .. import BaseViewSet
from plane.app.serializers import (
CycleIssueSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Cycle,
CycleIssue,
@@ -36,8 +44,8 @@ from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
from plane.app.permissions import allow_permission, ROLE
# Module imports
class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer
@@ -46,6 +54,10 @@ class CycleIssueViewSet(BaseViewSet):
webhook_event = "cycle_issue"
bulk = True
permission_classes = [
ProjectEntityPermission,
]
filterset_fields = [
"issue__labels__id",
"issue__assignees__id",
@@ -80,7 +92,6 @@ class CycleIssueViewSet(BaseViewSet):
)
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def list(self, request, slug, project_id, cycle_id):
order_by_param = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET")
@@ -227,7 +238,6 @@ class CycleIssueViewSet(BaseViewSet):
),
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, cycle_id):
issues = request.data.get("issues", [])
@@ -323,9 +333,8 @@ class CycleIssueViewSet(BaseViewSet):
)
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, cycle_id, issue_id):
cycle_issue = CycleIssue.objects.filter(
cycle_issue = CycleIssue.objects.get(
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,
+115 -106
View File
@@ -43,7 +43,6 @@ from plane.db.models import (
ProjectMember,
User,
Widget,
WorkspaceMember,
)
from plane.utils.issue_filters import issue_filters
@@ -52,61 +51,36 @@ from .. import BaseAPIView
def dashboard_overview_stats(self, request, slug):
extra_filters = {}
if WorkspaceMember.objects.filter(
assigned_issues = Issue.issue_objects.filter(
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
member=request.user,
role=5,
is_active=True,
).exists():
extra_filters = {"created_by": request.user}
assignees__in=[request.user],
).count()
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],
)
.filter(**extra_filters)
.count()
)
pending_issues_count = Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
target_date__lt=timezone.now().date(),
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"]),
target_date__lt=timezone.now().date(),
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
assignees__in=[request.user],
)
.filter(**extra_filters)
.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()
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,
)
.filter(**extra_filters)
.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",
)
.filter(**extra_filters)
.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(
{
@@ -192,14 +166,6 @@ def dashboard_assigned_issues(self, request, slug):
)
)
if WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=5,
is_active=True,
).exists():
assigned_issues = assigned_issues.filter(created_by=request.user)
# Priority Ordering
priority_order = ["urgent", "high", "medium", "low", "none"]
assigned_issues = assigned_issues.annotate(
@@ -443,16 +409,6 @@ def dashboard_created_issues(self, request, slug):
def dashboard_issues_by_state_groups(self, request, slug):
filters = issue_filters(request.query_params, "GET")
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
extra_filters = {}
if WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=5,
is_active=True,
).exists():
extra_filters = {"created_by": request.user}
issues_by_state_groups = (
Issue.issue_objects.filter(
workspace__slug=slug,
@@ -460,7 +416,7 @@ def dashboard_issues_by_state_groups(self, request, slug):
project__project_projectmember__member=request.user,
assignees__in=[request.user],
)
.filter(**filters, **extra_filters)
.filter(**filters)
.values("state__group")
.annotate(count=Count("id"))
)
@@ -483,15 +439,6 @@ def dashboard_issues_by_state_groups(self, request, slug):
def dashboard_issues_by_priority(self, request, slug):
filters = issue_filters(request.query_params, "GET")
priority_order = ["urgent", "high", "medium", "low", "none"]
extra_filters = {}
if WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=5,
is_active=True,
).exists():
extra_filters = {"created_by": request.user}
issues_by_priority = (
Issue.issue_objects.filter(
@@ -500,7 +447,7 @@ def dashboard_issues_by_priority(self, request, slug):
project__project_projectmember__member=request.user,
assignees__in=[request.user],
)
.filter(**filters, **extra_filters)
.filter(**filters)
.values("priority")
.annotate(count=Count("id"))
)
@@ -574,42 +521,104 @@ def dashboard_recent_projects(self, request, slug):
def dashboard_recent_collaborators(self, request, slug):
project_members_with_activities = (
WorkspaceMember.objects.filter(
# Subquery to count activities for each project member
activity_count_subquery = (
IssueActivity.objects.filter(
workspace__slug=slug,
is_active=True,
actor=OuterRef("member"),
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.values("actor")
.annotate(num_activities=Count("pk"))
.values("num_activities")
)
# Get all project members and annotate them with activity counts
project_members_with_activities = (
ProjectMember.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.annotate(
active_issue_count=Count(
Case(
When(
member__issue_assignee__issue__state__group__in=[
"unstarted",
"started",
],
member__issue_assignee__issue__workspace__slug=slug,
member__issue_assignee__issue__project__project_projectmember__member=request.user,
member__issue_assignee__issue__project__project_projectmember__is_active=True,
then=F("member__issue_assignee__issue__id"),
),
distinct=True,
output_field=IntegerField(),
),
distinct=True,
num_activities=Coalesce(
Subquery(activity_count_subquery),
Value(0),
output_field=IntegerField(),
),
is_current_user=Case(
When(member=request.user, then=Value(0)),
default=Value(1),
output_field=IntegerField(),
),
user_id=F("member_id"),
)
.values("user_id", "active_issue_count")
.order_by("-active_issue_count")
.values_list("member", flat=True)
.order_by("is_current_user", "-num_activities")
.distinct()
)
return Response(
(project_members_with_activities),
status=status.HTTP_200_OK,
search = request.query_params.get("search", None)
if search:
project_members_with_activities = (
project_members_with_activities.filter(
Q(member__display_name__icontains=search)
| Q(member__first_name__icontains=search)
| Q(member__last_name__icontains=search)
)
)
return self.paginate(
request=request,
queryset=project_members_with_activities,
controller=lambda qs: self.get_results_controller(qs, slug),
)
class DashboardEndpoint(BaseAPIView):
def get_results_controller(self, project_members_with_activities, slug):
user_active_issue_counts = (
User.objects.filter(
id__in=project_members_with_activities,
)
.annotate(
active_issue_count=Count(
Case(
When(
issue_assignee__issue__state__group__in=[
"unstarted",
"started",
],
issue_assignee__issue__workspace__slug=slug,
issue_assignee__issue__project__project_projectmember__is_active=True,
then=F("issue_assignee__issue__id"),
),
output_field=IntegerField(),
),
distinct=True,
)
)
.values("active_issue_count", user_id=F("id"))
)
# Create a dictionary to store the active issue counts by user ID
active_issue_counts_dict = {
user["user_id"]: user["active_issue_count"]
for user in user_active_issue_counts
}
# Preserve the sequence of project members with activities
paginated_results = [
{
"user_id": member_id,
"active_issue_count": active_issue_counts_dict.get(
member_id, 0
),
}
for member_id in project_members_with_activities
]
return paginated_results
def create(self, request, slug):
serializer = DashboardSerializer(data=request.data)
if serializer.is_valid():
+7 -9
View File
@@ -7,11 +7,7 @@ from rest_framework import status
# Module imports
from ..base import BaseViewSet, BaseAPIView
from plane.app.permissions import (
ProjectEntityPermission,
allow_permission,
ROLE,
)
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Project, Estimate, EstimatePoint, Issue
from plane.app.serializers import (
EstimateSerializer,
@@ -27,8 +23,10 @@ def generate_random_name(length=10):
class ProjectEstimatePointEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def get(self, request, slug, project_id):
project = Project.objects.get(workspace__slug=slug, pk=project_id)
if project.estimate_id is not None:
@@ -191,8 +189,10 @@ class BulkEstimatePointEndpoint(BaseViewSet):
class EstimatePointEndpoint(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, estimate_id):
# TODO: add a key validation if the same key already exists
if not request.data.get("key") or not request.data.get("value"):
@@ -211,7 +211,6 @@ class EstimatePointEndpoint(BaseViewSet):
serializer = EstimatePointSerializer(estimate_point).data
return Response(serializer, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(
self, request, slug, project_id, estimate_id, estimate_point_id
):
@@ -232,7 +231,6 @@ class EstimatePointEndpoint(BaseViewSet):
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(
self, request, slug, project_id, estimate_id, estimate_point_id
):
+5 -8
View File
@@ -2,7 +2,7 @@
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import WorkSpaceAdminPermission
from plane.app.serializers import ExporterHistorySerializer
from plane.bgtasks.export_task import issue_export_task
from plane.db.models import ExporterHistory, Project, Workspace
@@ -12,10 +12,12 @@ from .. import BaseAPIView
class ExportIssuesEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
model = ExporterHistory
serializer_class = ExporterHistorySerializer
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug):
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
@@ -39,7 +41,6 @@ class ExportIssuesEndpoint(BaseAPIView):
project=project_ids,
initiated_by=request.user,
provider=provider,
type="issue_exports",
)
issue_export_task.delay(
@@ -62,13 +63,9 @@ class ExportIssuesEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def get(self, request, slug):
exporter_history = ExporterHistory.objects.filter(
workspace__slug=slug,
type="issue_exports",
workspace__slug=slug
).select_related("workspace", "initiated_by")
if request.GET.get("per_page", False) and request.GET.get(
+7 -5
View File
@@ -11,7 +11,7 @@ from rest_framework import status
# Module imports
from ..base import BaseAPIView
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import ProjectEntityPermission, WorkspaceEntityPermission
from plane.db.models import Workspace, Project
from plane.app.serializers import (
ProjectLiteSerializer,
@@ -21,8 +21,10 @@ from plane.license.utils.instance_value import get_configuration_value
class GPTIntegrationEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id):
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
[
@@ -82,10 +84,10 @@ class GPTIntegrationEndpoint(BaseAPIView):
class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def post(self, request, slug):
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
[
+41 -40
View File
@@ -16,7 +16,7 @@ from rest_framework.response import Response
# Module imports
from ..base import BaseViewSet
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import ProjectBasePermission, ProjectLitePermission
from plane.db.models import (
Inbox,
InboxIssue,
@@ -35,10 +35,13 @@ from plane.app.serializers import (
InboxIssueDetailSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
class InboxViewSet(BaseViewSet):
permission_classes = [
ProjectBasePermission,
]
serializer_class = InboxSerializer
model = Inbox
@@ -60,7 +63,6 @@ class InboxViewSet(BaseViewSet):
.select_related("workspace", "project")
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def list(self, request, slug, project_id):
inbox = self.get_queryset().first()
return Response(
@@ -68,11 +70,9 @@ class InboxViewSet(BaseViewSet):
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id"))
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, pk):
inbox = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id, pk=pk
@@ -88,6 +88,9 @@ class InboxViewSet(BaseViewSet):
class InboxIssueViewSet(BaseViewSet):
permission_classes = [
ProjectLitePermission,
]
serializer_class = InboxIssueSerializer
model = InboxIssue
@@ -157,19 +160,17 @@ class InboxIssueViewSet(BaseViewSet):
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct()
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def list(self, request, slug, project_id):
inbox_id = Inbox.objects.get(
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
)
).first()
filters = issue_filters(request.GET, "GET", "issue__")
inbox_issue = (
InboxIssue.objects.filter(
@@ -199,14 +200,6 @@ class InboxIssueViewSet(BaseViewSet):
if inbox_status:
inbox_issue = inbox_issue.filter(status__in=inbox_status)
if ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
is_active=True,
).exists():
inbox_issue = inbox_issue.filter(created_by=request.user)
return self.paginate(
request=request,
queryset=(inbox_issue),
@@ -216,7 +209,6 @@ class InboxIssueViewSet(BaseViewSet):
).data,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def create(self, request, slug, project_id):
if not request.data.get("issue", {}).get("name", False):
return Response(
@@ -319,13 +311,12 @@ class InboxIssueViewSet(BaseViewSet):
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk):
def partial_update(self, request, slug, project_id, issue_id):
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
inbox_issue = InboxIssue.objects.get(
issue_id=pk,
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,
inbox_id=inbox_id,
@@ -466,7 +457,7 @@ class InboxIssueViewSet(BaseViewSet):
request.data, cls=DjangoJSONEncoder
),
actor_id=str(request.user.id),
issue_id=str(pk),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
@@ -501,7 +492,7 @@ class InboxIssueViewSet(BaseViewSet):
)
.get(
inbox_id=inbox_id.id,
issue_id=pk,
issue_id=issue_id,
project_id=project_id,
)
)
@@ -514,15 +505,10 @@ class InboxIssueViewSet(BaseViewSet):
serializer = InboxIssueDetailSerializer(inbox_issue).data
return Response(serializer, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER],
creator=True,
model=Issue,
)
def retrieve(self, request, slug, project_id, pk):
inbox_id = Inbox.objects.get(
def retrieve(self, request, slug, project_id, issue_id):
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
)
).first()
inbox_issue = (
InboxIssue.objects.select_related("issue")
.prefetch_related(
@@ -547,7 +533,9 @@ class InboxIssueViewSet(BaseViewSet):
Value([], output_field=ArrayField(UUIDField())),
),
)
.get(inbox_id=inbox_id.id, issue_id=pk, project_id=project_id)
.get(
inbox_id=inbox_id.id, issue_id=issue_id, project_id=project_id
)
)
issue = InboxIssueDetailSerializer(inbox_issue).data
return Response(
@@ -555,25 +543,38 @@ class InboxIssueViewSet(BaseViewSet):
status=status.HTTP_200_OK,
)
@allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue)
def destroy(self, request, slug, project_id, pk):
def destroy(self, request, slug, project_id, issue_id):
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
inbox_issue = InboxIssue.objects.get(
issue_id=pk,
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,
inbox_id=inbox_id,
)
# Get the project member
project_member = ProjectMember.objects.get(
workspace__slug=slug,
project_id=project_id,
member=request.user,
is_active=True,
)
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
request.user.id
):
return Response(
{"error": "You cannot delete inbox issue"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check the issue status
if inbox_issue.status in [-2, -1, 0, 2]:
# Delete the issue also
issue = Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk=pk
).first()
issue.delete()
Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk=issue_id
).delete()
inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+1 -2
View File
@@ -19,7 +19,7 @@ from plane.app.serializers import (
IssueActivitySerializer,
IssueCommentSerializer,
)
from plane.app.permissions import ProjectEntityPermission, allow_permission, ROLE
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
IssueActivity,
IssueComment,
@@ -33,7 +33,6 @@ class IssueActivityEndpoint(BaseAPIView):
]
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
def get(self, request, slug, project_id, issue_id):
filters = {}
if request.GET.get("created_at__gt", None) is not None:
+7 -14
View File
@@ -25,9 +25,9 @@ from plane.app.permissions import (
from plane.app.serializers import (
IssueFlatSerializer,
IssueSerializer,
IssueDetailSerializer,
IssueDetailSerializer
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueAttachment,
@@ -46,14 +46,15 @@ from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
from plane.app.permissions import allow_permission, ROLE
from plane.utils.error_codes import ERROR_CODES
# Module imports
from .. import BaseViewSet, BaseAPIView
class IssueArchiveViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = IssueFlatSerializer
model = Issue
@@ -65,7 +66,6 @@ class IssueArchiveViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(deleted_at__isnull=True)
.filter(archived_at__isnull=False)
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
@@ -97,7 +97,6 @@ class IssueArchiveViewSet(BaseViewSet):
)
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true")
@@ -213,7 +212,6 @@ class IssueArchiveViewSet(BaseViewSet):
),
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def retrieve(self, request, slug, project_id, pk=None):
issue = (
self.get_queryset()
@@ -257,7 +255,6 @@ class IssueArchiveViewSet(BaseViewSet):
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def archive(self, request, slug, project_id, pk=None):
issue = Issue.issue_objects.get(
workspace__slug=slug,
@@ -296,7 +293,6 @@ class IssueArchiveViewSet(BaseViewSet):
{"archived_at": str(issue.archived_at)}, status=status.HTTP_200_OK
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def unarchive(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
workspace__slug=slug,
@@ -328,7 +324,6 @@ class BulkArchiveIssuesEndpoint(BaseAPIView):
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", [])
@@ -346,10 +341,8 @@ class BulkArchiveIssuesEndpoint(BaseAPIView):
if issue.state.group not in ["completed", "cancelled"]:
return Response(
{
"error_code": ERROR_CODES[
"INVALID_ARCHIVE_STATE_GROUP"
],
"error_message": "INVALID_ARCHIVE_STATE_GROUP",
"error_code": 4091,
"error_message": "INVALID_ARCHIVE_STATE_GROUP"
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -13,17 +13,19 @@ from rest_framework.parsers import MultiPartParser, FormParser
# Module imports
from .. import BaseAPIView
from plane.app.serializers import IssueAttachmentSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import IssueAttachment
from plane.bgtasks.issue_activities_task import issue_activity
from plane.app.permissions import allow_permission, ROLE
from plane.bgtasks.issue_activites_task import issue_activity
class IssueAttachmentEndpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer
permission_classes = [
ProjectEntityPermission,
]
model = IssueAttachment
parser_classes = (MultiPartParser, FormParser)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def post(self, request, slug, project_id, issue_id):
serializer = IssueAttachmentSerializer(data=request.data)
if serializer.is_valid():
@@ -45,7 +47,6 @@ class IssueAttachmentEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN], creator=True, model=IssueAttachment)
def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment = IssueAttachment.objects.get(pk=pk)
issue_attachment.asset.delete(save=False)
@@ -64,7 +65,6 @@ class IssueAttachmentEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
def get(self, request, slug, project_id, issue_id):
issue_attachments = IssueAttachment.objects.filter(
issue_id=issue_id, workspace__slug=slug, project_id=project_id
+32 -249
View File
@@ -25,23 +25,25 @@ from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
)
from plane.app.serializers import (
IssueCreateSerializer,
IssueDetailSerializer,
IssueUserPropertySerializer,
IssuePropertySerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueAttachment,
IssueLink,
IssueUserProperty,
IssueProperty,
IssueReaction,
IssueSubscriber,
Project,
ProjectMember,
)
from plane.utils.grouper import (
issue_group_values,
@@ -56,13 +58,16 @@ from plane.utils.paginator import (
)
from .. import BaseAPIView, BaseViewSet
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.global_paginator import paginate
from plane.bgtasks.webhook_task import model_activity
# Module imports
class IssueListEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
issue_ids = request.GET.get("issues", False)
@@ -129,14 +134,6 @@ class IssueListEndpoint(BaseAPIView):
sub_group_by=sub_group_by,
)
recent_visited_task.delay(
slug=slug,
project_id=project_id,
entity_name="project",
entity_identifier=project_id,
user_id=request.user.id,
)
if self.fields or self.expand:
issues = IssueSerializer(
queryset, many=True, fields=self.fields, expand=self.expand
@@ -168,7 +165,6 @@ class IssueListEndpoint(BaseAPIView):
"link_count",
"is_draft",
"archived_at",
"deleted_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
@@ -187,6 +183,9 @@ class IssueViewSet(BaseViewSet):
model = Issue
webhook_event = "issue"
permission_classes = [
ProjectEntityPermission,
]
search_fields = [
"name",
@@ -232,7 +231,6 @@ class IssueViewSet(BaseViewSet):
).distinct()
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
@@ -257,22 +255,6 @@ class IssueViewSet(BaseViewSet):
sub_group_by=sub_group_by,
)
recent_visited_task.delay(
slug=slug,
project_id=project_id,
entity_name="project",
entity_identifier=project_id,
user_id=request.user.id,
)
if ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
is_active=True,
).exists():
issue_queryset = issue_queryset.filter(created_by=request.user)
if group_by:
if sub_group_by:
if group_by == sub_group_by:
@@ -354,7 +336,6 @@ class IssueViewSet(BaseViewSet):
),
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
@@ -418,7 +399,6 @@ class IssueViewSet(BaseViewSet):
"link_count",
"is_draft",
"archived_at",
"deleted_at",
)
.first()
)
@@ -426,22 +406,9 @@ class IssueViewSet(BaseViewSet):
issue = user_timezone_converter(
issue, datetime_fields, request.user.user_timezone
)
# Send the model activity
model_activity.delay(
model_name="issue",
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], creator=True, model=Issue
)
def retrieve(self, request, slug, project_id, pk=None):
issue = (
self.get_queryset()
@@ -468,8 +435,7 @@ class IssueViewSet(BaseViewSet):
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -511,51 +477,11 @@ class IssueViewSet(BaseViewSet):
status=status.HTTP_404_NOT_FOUND,
)
recent_visited_task.delay(
slug=slug,
entity_name="issue",
entity_identifier=pk,
user_id=request.user.id,
project_id=project_id,
)
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk=None):
issue = (
self.get_queryset()
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.filter(pk=pk)
.first()
)
issue = self.get_queryset().filter(pk=pk).first()
if not issue:
return Response(
@@ -584,24 +510,14 @@ class IssueViewSet(BaseViewSet):
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
model_activity.delay(
model_name="issue",
model_id=str(serializer.data.get("id", None)),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
issue = self.get_queryset().filter(pk=pk).first()
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN], creator=True, model=Issue)
def destroy(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
issue.delete()
issue_activity.delay(
type="issue.activity.deleted",
@@ -618,9 +534,12 @@ class IssueViewSet(BaseViewSet):
class IssueUserDisplayPropertyEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
permission_classes = [
ProjectLitePermission,
]
def patch(self, request, slug, project_id):
issue_property = IssueUserProperty.objects.get(
issue_property = IssueProperty.objects.get(
user=request.user,
project_id=project_id,
)
@@ -635,20 +554,22 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
"display_properties", issue_property.display_properties
)
issue_property.save()
serializer = IssueUserPropertySerializer(issue_property)
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
def get(self, request, slug, project_id):
issue_property, _ = IssueUserProperty.objects.get_or_create(
issue_property, _ = IssueProperty.objects.get_or_create(
user=request.user, project_id=project_id
)
serializer = IssueUserPropertySerializer(issue_property)
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_200_OK)
class BulkDeleteIssuesEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN])
permission_classes = [
ProjectEntityPermission,
]
def delete(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", [])
@@ -670,141 +591,3 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
{"message": f"{total_issues} issues were deleted"},
status=status.HTTP_200_OK,
)
class IssuePaginatedViewSet(BaseViewSet):
def get_queryset(self):
workspace_slug = self.kwargs.get("slug")
project_id = self.kwargs.get("project_id")
return (
Issue.issue_objects.filter(
workspace__slug=workspace_slug, project_id=project_id
)
.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")
)
).distinct()
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
# converting the datetime fields in paginated data
datetime_fields = ["created_at", "updated_at"]
paginated_data = user_timezone_converter(
paginated_data, datetime_fields, timezone
)
return paginated_data
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def list(self, request, slug, project_id):
cursor = request.GET.get("cursor", None)
is_description_required = request.GET.get("description", False)
updated_at = request.GET.get("updated_at__gte", None)
# required fields
required_fields = [
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"created_at",
"updated_at",
"created_by",
"updated_by",
"is_draft",
"archived_at",
"deleted_at",
"module_ids",
"label_ids",
"assignee_ids",
"link_count",
"attachment_count",
"sub_issues_count",
]
if is_description_required:
required_fields.append("description_html")
# querying issues
base_queryset = Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id
).order_by("updated_at")
queryset = self.get_queryset().order_by("updated_at")
# filtering issues by greater then updated_at given by the user
if updated_at:
base_queryset = base_queryset.filter(updated_at__gte=updated_at)
queryset = queryset.filter(updated_at__gte=updated_at)
queryset = queryset.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
paginated_data = paginate(
base_queryset=base_queryset,
queryset=queryset,
cursor=cursor,
on_result=lambda results: self.process_paginated_result(
required_fields, results, request.user.user_timezone
),
)
return Response(paginated_data, status=status.HTTP_200_OK)
@@ -0,0 +1,288 @@
# Python imports
import json
from datetime import datetime
# Django imports
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.app.permissions import (
ProjectEntityPermission,
)
from plane.db.models import (
Project,
Issue,
IssueLabel,
IssueAssignee,
)
from plane.bgtasks.issue_activites_task import issue_activity
class BulkIssueOperationsEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def post(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", [])
if not len(issue_ids):
return Response(
{"error": "Issue IDs are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get all the issues
issues = (
Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
)
.select_related("state")
.prefetch_related("labels", "assignees")
)
# Current epoch
epoch = int(timezone.now().timestamp())
# Project details
project = Project.objects.get(workspace__slug=slug, pk=project_id)
workspace_id = project.workspace_id
# Initialize arrays
bulk_update_issues = []
bulk_issue_activities = []
bulk_update_issue_labels = []
bulk_update_issue_assignees = []
properties = request.data.get("properties", {})
if properties.get("start_date", False) and properties.get("target_date", False):
if (
datetime.strptime(properties.get("start_date"), "%Y-%m-%d").date()
> datetime.strptime(properties.get("target_date"), "%Y-%m-%d").date()
):
return Response(
{
"error_code": 4100,
"error_message": "INVALID_ISSUE_DATES",
},
status=status.HTTP_400_BAD_REQUEST,
)
for issue in issues:
# Priority
if properties.get("priority", False):
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"priority": properties.get("priority")}
),
"current_instance": json.dumps(
{"priority": (issue.priority)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.priority = properties.get("priority")
# State
if properties.get("state_id", False):
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"state": properties.get("state")}
),
"current_instance": json.dumps(
{"state": str(issue.state_id)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.state_id = properties.get("state_id")
# Start date
if properties.get("start_date", False):
if (
issue.target_date
and not properties.get("target_date", False)
and issue.target_date
<= datetime.strptime(
properties.get("start_date"), "%Y-%m-%d"
).date()
):
return Response(
{
"error_code": 4101,
"error_message": "INVALID_ISSUE_START_DATE",
},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"start_date": properties.get("start_date")}
),
"current_instance": json.dumps(
{"start_date": str(issue.start_date)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.start_date = properties.get("start_date")
# Target date
if properties.get("target_date", False):
if (
issue.start_date
and not properties.get("start_date", False)
and issue.start_date
>= datetime.strptime(
properties.get("target_date"), "%Y-%m-%d"
).date()
):
return Response(
{
"error_code": 4102,
"error_message": "INVALID_ISSUE_TARGET_DATE",
},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"target_date": properties.get("target_date")}
),
"current_instance": json.dumps(
{"target_date": str(issue.target_date)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.target_date = properties.get("target_date")
bulk_update_issues.append(issue)
# Labels
if properties.get("label_ids", []):
for label_id in properties.get("label_ids", []):
bulk_update_issue_labels.append(
IssueLabel(
issue=issue,
label_id=label_id,
created_by=request.user,
project_id=project_id,
workspace_id=workspace_id,
)
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"label_ids": properties.get("label_ids", [])}
),
"current_instance": json.dumps(
{
"label_ids": [
str(label.id)
for label in issue.labels.all()
]
}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
# Assignees
if properties.get("assignee_ids", []):
for assignee_id in properties.get(
"assignee_ids", issue.assignees
):
bulk_update_issue_assignees.append(
IssueAssignee(
issue=issue,
assignee_id=assignee_id,
created_by=request.user,
project_id=project_id,
workspace_id=workspace_id,
)
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{
"assignee_ids": properties.get(
"assignee_ids", []
)
}
),
"current_instance": json.dumps(
{
"assignee_ids": [
str(assignee.id)
for assignee in issue.assignees.all()
]
}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
# Bulk update all the objects
Issue.objects.bulk_update(
bulk_update_issues,
[
"priority",
"start_date",
"target_date",
"state",
],
batch_size=100,
)
# Create new labels
IssueLabel.objects.bulk_create(
bulk_update_issue_labels,
ignore_conflicts=True,
batch_size=100,
)
# Create new assignees
IssueAssignee.objects.bulk_create(
bulk_update_issue_assignees,
ignore_conflicts=True,
batch_size=100,
)
# update the issue activity
[
issue_activity.delay(**activity)
for activity in bulk_issue_activities
]
return Response(status=status.HTTP_204_NO_CONTENT)
+5 -11
View File
@@ -16,19 +16,22 @@ from plane.app.serializers import (
IssueCommentSerializer,
CommentReactionSerializer,
)
from plane.app.permissions import ProjectLitePermission, allow_permission, ROLE
from plane.app.permissions import ProjectLitePermission
from plane.db.models import (
IssueComment,
ProjectMember,
CommentReaction,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
webhook_event = "issue_comment"
permission_classes = [
ProjectLitePermission,
]
filterset_fields = [
"issue__id",
@@ -63,7 +66,6 @@ class IssueCommentViewSet(BaseViewSet):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
def create(self, request, slug, project_id, issue_id):
serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid():
@@ -88,11 +90,6 @@ class IssueCommentViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER],
creator=True,
model=IssueComment,
)
def partial_update(self, request, slug, project_id, issue_id, pk):
issue_comment = IssueComment.objects.get(
workspace__slug=slug,
@@ -124,9 +121,6 @@ class IssueCommentViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN], creator=True, model=IssueComment
)
def destroy(self, request, slug, project_id, issue_id, pk):
issue_comment = IssueComment.objects.get(
workspace__slug=slug,
+1 -16
View File
@@ -32,7 +32,7 @@ from plane.app.serializers import (
IssueFlatSerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueAttachment,
@@ -40,7 +40,6 @@ from plane.db.models import (
IssueReaction,
IssueSubscriber,
Project,
ProjectMember,
)
from plane.utils.grouper import (
issue_group_values,
@@ -68,7 +67,6 @@ class IssueDraftViewSet(BaseViewSet):
Issue.objects.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(is_draft=True)
.filter(deleted_at__isnull=True)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
@@ -382,19 +380,6 @@ class IssueDraftViewSet(BaseViewSet):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if issue.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the issue"},
status=status.HTTP_403_FORBIDDEN,
)
issue.delete()
issue_activity.delay(
type="issue_draft.activity.deleted",
+4 -6
View File
@@ -11,7 +11,9 @@ from rest_framework import status
# Module imports
from .. import BaseViewSet, BaseAPIView
from plane.app.serializers import LabelSerializer
from plane.app.permissions import allow_permission, ProjectBasePermission, ROLE
from plane.app.permissions import (
ProjectMemberPermission,
)
from plane.db.models import (
Project,
Label,
@@ -23,7 +25,7 @@ class LabelViewSet(BaseViewSet):
serializer_class = LabelSerializer
model = Label
permission_classes = [
ProjectBasePermission,
ProjectMemberPermission,
]
def get_queryset(self):
@@ -43,7 +45,6 @@ class LabelViewSet(BaseViewSet):
@invalidate_cache(
path="/api/workspaces/:slug/labels/", url_params=True, user=False
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
try:
serializer = LabelSerializer(data=request.data)
@@ -66,20 +67,17 @@ class LabelViewSet(BaseViewSet):
@invalidate_cache(
path="/api/workspaces/:slug/labels/", url_params=True, user=False
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
@invalidate_cache(
path="/api/workspaces/:slug/labels/", url_params=True, user=False
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
class BulkCreateIssueLabelsEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN])
def post(self, request, slug, project_id):
label_data = request.data.get("label_data", [])
project = Project.objects.get(pk=project_id)
+1 -1
View File
@@ -14,7 +14,7 @@ from .. import BaseViewSet
from plane.app.serializers import IssueLinkSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import IssueLink
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
class IssueLinkViewSet(BaseViewSet):
+1 -1
View File
@@ -14,7 +14,7 @@ from .. import BaseViewSet
from plane.app.serializers import IssueReactionSerializer
from plane.app.permissions import ProjectLitePermission
from plane.db.models import IssueReaction
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
class IssueReactionViewSet(BaseViewSet):
+54 -131
View File
@@ -3,11 +3,8 @@ import json
# Django imports
from django.utils import timezone
from django.db.models import Q, OuterRef, F, Func, UUIDField, Value, CharField
from django.db.models import Q
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
# Third Party imports
from rest_framework.response import Response
@@ -23,11 +20,8 @@ from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
Project,
IssueRelation,
Issue,
IssueAttachment,
IssueLink,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
class IssueRelationViewSet(BaseViewSet):
@@ -37,6 +31,24 @@ class IssueRelationViewSet(BaseViewSet):
ProjectEntityPermission,
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.select_related("project")
.select_related("workspace")
.select_related("issue")
.distinct()
)
def list(self, request, slug, project_id, issue_id):
issue_relations = (
IssueRelation.objects.filter(
@@ -49,145 +61,56 @@ class IssueRelationViewSet(BaseViewSet):
.order_by("-created_at")
.distinct()
)
# get all blocking issues
blocking_issues = issue_relations.filter(
relation_type="blocked_by", related_issue_id=issue_id
).values_list("issue_id", flat=True)
# get all blocked by issues
)
blocked_by_issues = issue_relations.filter(
relation_type="blocked_by", issue_id=issue_id
).values_list("related_issue_id", flat=True)
# get all duplicate issues
)
duplicate_issues = issue_relations.filter(
issue_id=issue_id, relation_type="duplicate"
).values_list("related_issue_id", flat=True)
# get all relates to issues
)
duplicate_issues_related = issue_relations.filter(
related_issue_id=issue_id, relation_type="duplicate"
).values_list("issue_id", flat=True)
# get all relates to issues
)
relates_to_issues = issue_relations.filter(
issue_id=issue_id, relation_type="relates_to"
).values_list("related_issue_id", flat=True)
# get all relates to issues
)
relates_to_issues_related = issue_relations.filter(
related_issue_id=issue_id, relation_type="relates_to"
).values_list("issue_id", flat=True)
)
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.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")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct()
blocked_by_issues_serialized = IssueRelationSerializer(
blocked_by_issues, many=True
).data
duplicate_issues_serialized = IssueRelationSerializer(
duplicate_issues, many=True
).data
relates_to_issues_serialized = IssueRelationSerializer(
relates_to_issues, many=True
).data
# Fields
fields = [
"id",
"name",
"state_id",
"sort_order",
"priority",
"sequence_id",
"project_id",
"label_ids",
"assignee_ids",
"created_at",
"updated_at",
"created_by",
"updated_by",
"relation_type",
]
# revere relation for blocked by issues
blocking_issues_serialized = RelatedIssueSerializer(
blocking_issues, many=True
).data
# reverse relation for duplicate issues
duplicate_issues_related_serialized = RelatedIssueSerializer(
duplicate_issues_related, many=True
).data
# reverse relation for related issues
relates_to_issues_related_serialized = RelatedIssueSerializer(
relates_to_issues_related, many=True
).data
response_data = {
"blocking": queryset.filter(pk__in=blocking_issues)
.annotate(
relation_type=Value("blocking", output_field=CharField())
)
.values(*fields),
"blocked_by": queryset.filter(pk__in=blocked_by_issues)
.annotate(
relation_type=Value("blocked_by", output_field=CharField())
)
.values(*fields),
"duplicate": queryset.filter(pk__in=duplicate_issues)
.annotate(
relation_type=Value(
"duplicate",
output_field=CharField(),
)
)
.values(*fields)
| queryset.filter(pk__in=duplicate_issues_related)
.annotate(
relation_type=Value(
"duplicate",
output_field=CharField(),
)
)
.values(*fields),
"relates_to": queryset.filter(pk__in=relates_to_issues)
.annotate(
relation_type=Value(
"relates_to",
output_field=CharField(),
)
)
.values(*fields)
| queryset.filter(pk__in=relates_to_issues_related)
.annotate(
relation_type=Value(
"relates_to",
output_field=CharField(),
)
)
.values(*fields),
"blocking": blocking_issues_serialized,
"blocked_by": blocked_by_issues_serialized,
"duplicate": duplicate_issues_serialized
+ duplicate_issues_related_serialized,
"relates_to": relates_to_issues_serialized
+ relates_to_issues_related_serialized,
}
return Response(response_data, status=status.HTTP_200_OK)
+1 -1
View File
@@ -30,7 +30,7 @@ from plane.db.models import (
IssueLink,
IssueAttachment,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.user_timezone_converter import user_timezone_converter
from collections import defaultdict
+15 -142
View File
@@ -12,8 +12,7 @@ from django.db.models import (
Subquery,
UUIDField,
Value,
Sum,
FloatField,
Sum
)
from django.db.models.functions import Coalesce, Cast
from django.utils import timezone
@@ -45,8 +44,8 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
def get_queryset(self):
favorite_subquery = UserFavorite.objects.filter(
user=self.request.user,
entity_type="module",
entity_identifier=OuterRef("pk"),
entity_type="module",
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
@@ -103,93 +102,8 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
.annotate(cnt=Count("pk"))
.values("cnt")
)
completed_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="completed",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
completed_estimate_points=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("completed_estimate_points")[:1]
)
total_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
total_estimate_points=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("total_estimate_points")[:1]
)
backlog_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="backlog",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
backlog_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("backlog_estimate_point")[:1]
)
unstarted_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="unstarted",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
unstarted_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("unstarted_estimate_point")[:1]
)
started_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="started",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
started_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("started_estimate_point")[:1]
)
cancelled_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="cancelled",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
cancelled_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.values("cancelled_estimate_point")[:1]
)
return (
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(archived_at__isnull=False)
.annotate(is_favorite=Exists(favorite_subquery))
.select_related("workspace", "project", "lead")
@@ -238,42 +152,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
Value(0, output_field=IntegerField()),
)
)
.annotate(
backlog_estimate_points=Coalesce(
Subquery(backlog_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
unstarted_estimate_points=Coalesce(
Subquery(unstarted_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
started_estimate_points=Coalesce(
Subquery(started_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
cancelled_estimate_points=Coalesce(
Subquery(cancelled_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
completed_estimate_points=Coalesce(
Subquery(completed_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
total_estimate_points=Coalesce(
Subquery(total_estimate_point),
Value(0, output_field=FloatField()),
),
)
.annotate(
member_ids=Coalesce(
ArrayAgg(
@@ -354,7 +232,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
data["estimate_distribution"] = {}
if estimate_type:
assignee_distribution = (
label_distribution = (
Issue.issue_objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
@@ -374,12 +252,12 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", FloatField())
Cast("estimate_point__value", IntegerField())
),
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
@@ -389,7 +267,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
@@ -400,7 +278,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
.order_by("first_name", "last_name")
)
label_distribution = (
assignee_distribution = (
Issue.issue_objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
@@ -412,12 +290,12 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
.values("label_name", "color", "label_id")
.annotate(
total_estimates=Sum(
Cast("estimate_point__value", FloatField())
Cast("estimate_point__value", IntegerField())
),
)
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
@@ -427,7 +305,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
Cast("estimate_point__value", IntegerField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
@@ -437,8 +315,8 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
.order_by("label_name")
)
data["estimate_distribution"]["assignees"] = assignee_distribution
data["estimate_distribution"]["labels"] = label_distribution
data["estimate_distribution"]["assignee"] = assignee_distribution
data["estimate_distribution"]["label"] = label_distribution
if modules and modules.start_date and modules.target_date:
data["estimate_distribution"]["completion_chart"] = (
@@ -450,7 +328,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
module_id=pk,
)
)
assignee_distribution = (
Issue.issue_objects.filter(
issue_module__module_id=pk,
@@ -476,7 +353,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
archived_at__isnull=True,
is_draft=False,
),
),
)
)
.annotate(
completed_issues=Count(
@@ -548,6 +425,8 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
"labels": label_distribution,
"completion_chart": {},
}
# Fetch the modules
if modules and modules.start_date and modules.target_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=modules,
@@ -575,12 +454,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
module.archived_at = timezone.now()
module.save()
UserFavorite.objects.filter(
entity_type="module",
entity_identifier=module_id,
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(
{"archived_at": str(module.archived_at)},
status=status.HTTP_200_OK,
+11 -45
View File
@@ -30,10 +30,8 @@ from rest_framework.response import Response
# Module imports
from plane.app.permissions import (
ProjectEntityPermission,
allow_permission,
ROLE,
ProjectLitePermission,
)
from plane.app.serializers import (
ModuleDetailSerializer,
ModuleLinkSerializer,
@@ -41,7 +39,7 @@ from plane.app.serializers import (
ModuleUserPropertiesSerializer,
ModuleWriteSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
Module,
@@ -55,11 +53,13 @@ from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.bgtasks.webhook_task import model_activity
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.recent_visited_task import recent_visited_task
class ModuleViewSet(BaseViewSet):
model = Module
permission_classes = [
ProjectEntityPermission,
]
webhook_event = "module"
def get_serializer_class(self):
@@ -317,8 +317,6 @@ class ModuleViewSet(BaseViewSet):
.order_by("-is_favorite", "-created_at")
)
allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def create(self, request, slug, project_id):
project = Project.objects.get(workspace__slug=slug, pk=project_id)
serializer = ModuleWriteSerializer(
@@ -381,8 +379,6 @@ class ModuleViewSet(BaseViewSet):
return Response(module, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def list(self, request, slug, project_id):
queryset = self.get_queryset().filter(archived_at__isnull=True)
if self.fields:
@@ -430,8 +426,6 @@ class ModuleViewSet(BaseViewSet):
)
return Response(modules, status=status.HTTP_200_OK)
allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def retrieve(self, request, slug, project_id, pk):
queryset = (
self.get_queryset()
@@ -449,12 +443,6 @@ class ModuleViewSet(BaseViewSet):
)
)
if not queryset.exists():
return Response(
{"error": "Module not found"},
status=status.HTTP_404_NOT_FOUND,
)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
@@ -566,7 +554,7 @@ class ModuleViewSet(BaseViewSet):
)
assignee_distribution = (
Issue.issue_objects.filter(
Issue.objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
@@ -616,7 +604,7 @@ class ModuleViewSet(BaseViewSet):
)
label_distribution = (
Issue.issue_objects.filter(
Issue.objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
@@ -671,20 +659,11 @@ class ModuleViewSet(BaseViewSet):
module_id=pk,
)
recent_visited_task.delay(
slug=slug,
entity_name="module",
entity_identifier=pk,
user_id=request.user.id,
project_id=project_id,
)
return Response(
data,
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
module = self.get_queryset().filter(pk=pk)
@@ -754,12 +733,10 @@ class ModuleViewSet(BaseViewSet):
return Response(module, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN], creator=True, model=Module)
def destroy(self, request, slug, project_id, 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
@@ -780,18 +757,6 @@ class ModuleViewSet(BaseViewSet):
for issue in module_issues
]
module.delete()
# Delete the module issues
ModuleIssue.objects.filter(
module=pk,
project_id=project_id,
).delete()
# Delete the user favorite module
UserFavorite.objects.filter(
user=request.user,
entity_type="module",
entity_identifier=pk,
project_id=project_id,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -855,13 +820,15 @@ class ModuleFavoriteViewSet(BaseViewSet):
entity_type="module",
entity_identifier=module_id,
)
module_favorite.delete(soft=False)
module_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleUserPropertiesEndpoint(BaseAPIView):
permission_classes = [
ProjectLitePermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def patch(self, request, slug, project_id, module_id):
module_properties = ModuleUserProperties.objects.get(
user=request.user,
@@ -884,7 +851,6 @@ class ModuleUserPropertiesEndpoint(BaseAPIView):
serializer = ModuleUserPropertiesSerializer(module_properties)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def get(self, request, slug, project_id, module_id):
module_properties, _ = ModuleUserProperties.objects.get_or_create(
user=request.user,
+13 -11
View File
@@ -17,11 +17,13 @@ from django.views.decorators.gzip import gzip_page
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import (
ProjectEntityPermission,
)
from plane.app.serializers import (
ModuleIssueSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueAttachment,
@@ -44,7 +46,6 @@ from plane.utils.paginator import (
# Module imports
from .. import BaseViewSet
class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
@@ -56,6 +57,10 @@ class ModuleIssueViewSet(BaseViewSet):
"issue__assignees__id",
]
permission_classes = [
ProjectEntityPermission,
]
def get_queryset(self):
return (
Issue.issue_objects.filter(
@@ -91,7 +96,6 @@ class ModuleIssueViewSet(BaseViewSet):
).distinct()
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def list(self, request, slug, project_id, module_id):
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters)
@@ -199,7 +203,6 @@ class ModuleIssueViewSet(BaseViewSet):
),
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
# create multiple issues inside a module
def create_module_issues(self, request, slug, project_id, module_id):
issues = request.data.get("issues", [])
@@ -241,13 +244,13 @@ class ModuleIssueViewSet(BaseViewSet):
]
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
# add multiple module inside an issue and remove multiple modules from an issue
def create_issue_modules(self, request, slug, project_id, issue_id):
modules = request.data.get("modules", [])
removed_modules = request.data.get("removed_modules", [])
project = Project.objects.get(pk=project_id)
if modules:
_ = ModuleIssue.objects.bulk_create(
[
@@ -281,7 +284,7 @@ class ModuleIssueViewSet(BaseViewSet):
]
for module_id in removed_modules:
module_issue = ModuleIssue.objects.filter(
module_issue = ModuleIssue.objects.get(
workspace__slug=slug,
project_id=project_id,
module_id=module_id,
@@ -294,7 +297,7 @@ class ModuleIssueViewSet(BaseViewSet):
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
{"module_name": module_issue.first().module.name}
{"module_name": module_issue.module.name}
),
epoch=int(timezone.now().timestamp()),
notification=True,
@@ -304,9 +307,8 @@ class ModuleIssueViewSet(BaseViewSet):
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, module_id, issue_id):
module_issue = ModuleIssue.objects.filter(
module_issue = ModuleIssue.objects.get(
workspace__slug=slug,
project_id=project_id,
module_id=module_id,
@@ -319,7 +321,7 @@ class ModuleIssueViewSet(BaseViewSet):
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
{"module_name": module_issue.first().module.name}
{"module_name": module_issue.module.name}
),
epoch=int(timezone.now().timestamp()),
notification=True,
+3 -63
View File
@@ -1,5 +1,5 @@
# Django imports
from django.db.models import Exists, OuterRef, Q, Case, When, BooleanField
from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
# Third party imports
@@ -19,7 +19,6 @@ from plane.db.models import (
WorkspaceMember,
)
from plane.utils.paginator import BasePaginator
from plane.app.permissions import allow_permission, ROLE
# Module imports
from ..base import BaseAPIView, BaseViewSet
@@ -40,17 +39,12 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
.select_related("workspace", "project," "triggered_by", "receiver")
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
)
def list(self, request, slug):
# Get query parameters
snoozed = request.GET.get("snoozed", "false")
archived = request.GET.get("archived", "false")
read = request.GET.get("read", None)
type = request.GET.get("type", "all")
mentioned = request.GET.get("mentioned", False)
q_filters = Q()
inbox_issue = Issue.objects.filter(
@@ -65,13 +59,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
)
.filter(entity_name="issue")
.annotate(is_inbox_issue=Exists(inbox_issue))
.annotate(
is_mentioned_notification=Case(
When(sender__icontains="mentioned", then=True),
default=False,
output_field=BooleanField(),
)
)
.select_related("workspace", "project", "triggered_by", "receiver")
.order_by("snoozed_till", "-created_at")
)
@@ -99,13 +86,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
if read == "true":
notifications = notifications.filter(read_at__isnull=False)
if mentioned:
notifications = notifications.filter(sender__icontains="mentioned")
else:
notifications = notifications.exclude(
sender__icontains="mentioned"
)
type = type.split(",")
# Subscribed issues
if "subscribed" in type:
@@ -173,10 +153,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
serializer = NotificationSerializer(notifications, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
)
def partial_update(self, request, slug, pk):
notification = Notification.objects.get(
workspace__slug=slug, pk=pk, receiver=request.user
@@ -194,10 +170,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
)
def mark_read(self, request, slug, pk):
notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk
@@ -207,9 +179,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def mark_unread(self, request, slug, pk):
notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk
@@ -219,9 +188,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def archive(self, request, slug, pk):
notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk
@@ -231,9 +197,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def unarchive(self, request, slug, pk):
notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk
@@ -245,50 +208,27 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
class UnreadNotificationEndpoint(BaseAPIView):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST],
level="WORKSPACE",
)
def get(self, request, slug):
# Watching Issues Count
unread_notifications_count = (
Notification.objects.filter(
workspace__slug=slug,
receiver_id=request.user.id,
read_at__isnull=True,
archived_at__isnull=True,
snoozed_till__isnull=True,
)
.exclude(sender__icontains="mentioned")
.count()
)
mention_notifications_count = Notification.objects.filter(
unread_notifications_count = Notification.objects.filter(
workspace__slug=slug,
receiver_id=request.user.id,
read_at__isnull=True,
archived_at__isnull=True,
snoozed_till__isnull=True,
sender__icontains="mentioned",
).count()
return Response(
{
"total_unread_notifications_count": int(
unread_notifications_count
),
"mention_unread_notifications_count": int(
mention_notifications_count
),
)
},
status=status.HTTP_200_OK,
)
class MarkAllReadNotificationViewSet(BaseViewSet):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def create(self, request, slug):
snoozed = request.data.get("snoozed", False)
archived = request.data.get("archived", False)
+39 -128
View File
@@ -19,7 +19,7 @@ from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
PageLogSerializer,
PageSerializer,
@@ -33,13 +33,11 @@ from plane.db.models import (
ProjectMember,
ProjectPage,
)
from plane.utils.error_codes import ERROR_CODES
# Module imports
from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
from plane.bgtasks.page_version_task import page_version
from plane.bgtasks.recent_visited_task import recent_visited_task
def unarchive_archive_page_and_descendants(page_id, archived_at):
@@ -61,6 +59,9 @@ def unarchive_archive_page_and_descendants(page_id, archived_at):
class PageViewSet(BaseViewSet):
serializer_class = PageSerializer
model = Page
permission_classes = [
ProjectEntityPermission,
]
search_fields = [
"name",
]
@@ -120,7 +121,6 @@ class PageViewSet(BaseViewSet):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
serializer = PageSerializer(
data=request.data,
@@ -142,7 +142,6 @@ class PageViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
try:
page = Page.objects.get(
@@ -208,7 +207,6 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def retrieve(self, request, slug, project_id, pk=None):
page = self.get_queryset().filter(pk=pk).first()
if page is None:
@@ -217,24 +215,10 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_404_NOT_FOUND,
)
else:
issue_ids = PageLog.objects.filter(
page_id=pk, entity_name="issue"
).values_list("entity_identifier", flat=True)
data = PageDetailSerializer(page).data
data["issue_ids"] = issue_ids
recent_visited_task.delay(
slug=slug,
entity_name="page",
entity_identifier=pk,
user_id=request.user.id,
project_id=project_id,
)
return Response(
data,
status=status.HTTP_200_OK,
PageDetailSerializer(page).data, status=status.HTTP_200_OK
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def lock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -244,7 +228,6 @@ class PageViewSet(BaseViewSet):
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def unlock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -255,36 +238,11 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def access(self, request, slug, project_id, pk):
access = request.data.get("access", 0)
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
).first()
# Only update access if the page owner is the requesting user
if (
page.access != request.data.get("access", page.access)
and page.owned_by_id != request.user.id
):
return Response(
{
"error": "Access cannot be updated since this page is owned by someone else"
},
status=status.HTTP_400_BAD_REQUEST,
)
page.access = access
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def list(self, request, slug, project_id):
queryset = self.get_queryset()
pages = PageSerializer(queryset, many=True).data
return Response(pages, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def archive(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -305,13 +263,6 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
UserFavorite.objects.filter(
entity_type="page",
entity_identifier=pk,
project_id=project_id,
workspace__slug=slug,
).delete()
unarchive_archive_page_and_descendants(pk, datetime.now())
return Response(
@@ -319,7 +270,6 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def unarchive(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -349,53 +299,48 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN], creator=True, model=Page)
def destroy(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, projects__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,
).exists()
or request.user.id != page.owned_by_id
):
return Response(
{"error": "Only the owner and admin can delete the page"},
status=status.HTTP_400_BAD_REQUEST,
)
if page.archived_at is None:
return Response(
{"error": "The page should be archived before deleting"},
status=status.HTTP_400_BAD_REQUEST,
)
if page.owned_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or owner can delete the page"},
status=status.HTTP_403_FORBIDDEN,
)
# remove parent from all the children
_ = Page.objects.filter(
parent_id=pk, projects__id=project_id, workspace__slug=slug
).update(parent=None)
page.delete()
# Delete the user favorite page
UserFavorite.objects.filter(
project=project_id,
workspace__slug=slug,
entity_identifier=pk,
entity_type="page",
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class PageFavoriteViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
model = UserFavorite
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, pk):
_ = UserFavorite.objects.create(
project_id=project_id,
@@ -405,7 +350,6 @@ class PageFavoriteViewSet(BaseViewSet):
)
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, pk):
page_favorite = UserFavorite.objects.get(
project=project_id,
@@ -414,11 +358,14 @@ class PageFavoriteViewSet(BaseViewSet):
entity_identifier=pk,
entity_type="page",
)
page_favorite.delete(soft=False)
page_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class PageLogEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = PageLogSerializer
model = PageLog
@@ -458,6 +405,9 @@ class PageLogEndpoint(BaseAPIView):
class SubPagesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@method_decorator(gzip_page)
def get(self, request, slug, project_id, page_id):
@@ -476,8 +426,10 @@ class SubPagesEndpoint(BaseAPIView):
class PagesDescriptionViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER])
def retrieve(self, request, slug, project_id, pk):
page = (
Page.objects.filter(
@@ -486,11 +438,6 @@ class PagesDescriptionViewSet(BaseViewSet):
.filter(Q(owned_by=self.request.user) | Q(access=0))
.first()
)
if page is None:
return Response(
{"error": "Page not found"},
status=404,
)
binary_data = page.description_binary
def stream_data():
@@ -507,7 +454,6 @@ class PagesDescriptionViewSet(BaseViewSet):
)
return response
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk):
page = (
Page.objects.filter(
@@ -517,63 +463,28 @@ class PagesDescriptionViewSet(BaseViewSet):
.first()
)
if page is None:
return Response(
{"error": "Page not found"},
status=404,
)
if page.is_locked:
return Response(
{
"error_code": ERROR_CODES["PAGE_LOCKED"],
"error_message": "PAGE_LOCKED",
},
status=status.HTTP_400_BAD_REQUEST,
{"error": "Page is locked"},
status=471,
)
if page.archived_at:
return Response(
{
"error_code": ERROR_CODES["PAGE_ARCHIVED"],
"error_message": "PAGE_ARCHIVED",
},
status=status.HTTP_400_BAD_REQUEST,
{"error": "Page is archived"},
status=472,
)
# Serialize the existing instance
existing_instance = json.dumps(
{
"description_html": page.description_html,
},
cls=DjangoJSONEncoder,
)
# Get the base64 data from the request
base64_data = request.data.get("description_binary")
# If base64 data is provided
if base64_data:
# Decode the base64 data to bytes
new_binary_data = base64.b64decode(base64_data)
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data,
old_value=existing_instance,
page_id=pk,
)
# Store the updated binary data
page.description_binary = new_binary_data
page.description_html = request.data.get("description_html")
page.description = request.data.get("description")
page.save()
# Return a success response
page_version.delay(
page_id=page.id,
existing_instance=existing_instance,
user_id=request.user.id,
)
return Response({"message": "Updated successfully"})
else:
return Response({"error": "No binary data provided"})
-39
View File
@@ -1,39 +0,0 @@
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.db.models import PageVersion
from ..base import BaseAPIView
from plane.app.serializers import (
PageVersionSerializer,
PageVersionDetailSerializer,
)
from plane.app.permissions import allow_permission, ROLE
class PageVersionEndpoint(BaseAPIView):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]
)
def get(self, request, slug, project_id, page_id, pk=None):
# Check if pk is provided
if pk:
# Return a single page version
page_version = PageVersion.objects.get(
workspace__slug=slug,
page_id=page_id,
pk=pk,
)
# Serialize the page version
serializer = PageVersionDetailSerializer(page_version)
return Response(serializer.data, status=status.HTTP_200_OK)
# Return all page versions
page_versions = PageVersion.objects.filter(
workspace__slug=slug,
page_id=page_id,
)
# Serialize the page versions
serializer = PageVersionSerializer(page_versions, many=True)
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