release: v0.27.0 #7296

This commit is contained in:
sriram veeraghanta 2025-07-01 17:40:56 +05:30 committed by GitHub
commit 5a3f709e72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1615 changed files with 27874 additions and 19715 deletions

View file

@ -2,6 +2,7 @@
*.pyc *.pyc
.env .env
venv venv
.venv
node_modules/ node_modules/
**/node_modules/ **/node_modules/
npm-debug.log npm-debug.log

3
.gitignore vendored
View file

@ -1,5 +1,6 @@
node_modules node_modules
.next .next
.yarn
### NextJS ### ### NextJS ###
# Dependencies # Dependencies
@ -52,6 +53,8 @@ mediafiles
.env .env
.DS_Store .DS_Store
logs/ logs/
htmlcov/
.coverage
node_modules/ node_modules/
assets/dist/ assets/dist/

1
.yarnrc.yml Normal file
View file

@ -0,0 +1 @@
nodeLinker: node-modules

View file

@ -35,7 +35,8 @@ This helps us triage and manage issues more efficiently.
### Requirements ### Requirements
- Node.js version v16.18.0 - Docker Engine installed and running
- Node.js version 20+ [LTS version](https://nodejs.org/en/about/previous-releases)
- Python version 3.8+ - Python version 3.8+
- Postgres version v14 - Postgres version v14
- Redis version v6.2.7 - Redis version v6.2.7
@ -68,6 +69,17 @@ chmod +x setup.sh
docker compose -f docker-compose-local.yml up docker compose -f docker-compose-local.yml up
``` ```
4. Start web apps:
```bash
yarn dev
```
5. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
6. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step
Thats it! Youre all set to begin coding. Remember to refresh your browser if changes dont auto-reload. Happy contributing! 🎉
## Missing a Feature? ## Missing a Feature?
If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository. If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository.

View file

@ -85,38 +85,7 @@ Access real-time insights across all your Plane data. Visualize trends, remove b
## 🛠️ Local development ## 🛠️ Local development
### Pre-requisites See [CONTRIBUTING](./CONTRIBUTING.md)
- Ensure Docker Engine is installed and running.
### Development setup
Setting up your local environment is simple and straightforward. Follow these steps to get started:
1. Clone the repository:
```
git clone https://github.com/makeplane/plane.git
```
2. Navigate to the project folder:
```
cd plane
```
3. Create a new branch for your feature or fix:
```
git checkout -b <feature-branch-name>
```
4. Run the setup script in the terminal:
```
./setup.sh
```
5. Open the project in an IDE such as VS Code.
6. Review the `.env` files in the relevant folders. Refer to [Environment Setup](./ENV_SETUP.md) for details on the environment variables used.
7. Start the services using Docker:
```
docker compose -f docker-compose-local.yml up -d
```
Thats it! Youre all set to begin coding. Remember to refresh your browser if changes dont auto-reload. Happy contributing! 🎉
## ⚙️ Built with ## ⚙️ Built with
[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/) [![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/)

View file

@ -1,3 +1,12 @@
NEXT_PUBLIC_API_BASE_URL="" NEXT_PUBLIC_API_BASE_URL="http://localhost:8000"
NEXT_PUBLIC_WEB_BASE_URL="http://localhost:3000"
NEXT_PUBLIC_ADMIN_BASE_URL="http://localhost:3001"
NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
NEXT_PUBLIC_WEB_BASE_URL=""
NEXT_PUBLIC_SPACE_BASE_URL="http://localhost:3002"
NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
NEXT_PUBLIC_LIVE_BASE_URL="http://localhost:3100"
NEXT_PUBLIC_LIVE_BASE_PATH="/live"

View file

@ -26,16 +26,16 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<AIFormValues>({ } = useForm<AIFormValues>({
defaultValues: { defaultValues: {
OPENAI_API_KEY: config["OPENAI_API_KEY"], LLM_API_KEY: config["LLM_API_KEY"],
GPT_ENGINE: config["GPT_ENGINE"], LLM_MODEL: config["LLM_MODEL"],
}, },
}); });
const aiFormFields: TControllerInputFormField[] = [ const aiFormFields: TControllerInputFormField[] = [
{ {
key: "GPT_ENGINE", key: "LLM_MODEL",
type: "text", type: "text",
label: "GPT_ENGINE", label: "LLM Model",
description: ( description: (
<> <>
Choose an OpenAI engine.{" "} Choose an OpenAI engine.{" "}
@ -49,12 +49,12 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
</a> </a>
</> </>
), ),
placeholder: "gpt-3.5-turbo", placeholder: "gpt-4o-mini",
error: Boolean(errors.GPT_ENGINE), error: Boolean(errors.LLM_MODEL),
required: false, required: false,
}, },
{ {
key: "OPENAI_API_KEY", key: "LLM_API_KEY",
type: "password", type: "password",
label: "API key", label: "API key",
description: ( description: (
@ -71,7 +71,7 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
</> </>
), ),
placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd", placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd",
error: Boolean(errors.OPENAI_API_KEY), error: Boolean(errors.LLM_API_KEY),
required: false, required: false,
}, },
]; ];

View file

@ -98,11 +98,7 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
key: "GITHUB_ORGANIZATION_ID", key: "GITHUB_ORGANIZATION_ID",
type: "text", type: "text",
label: "Organization ID", label: "Organization ID",
description: ( description: <>The organization github ID.</>,
<>
The organization github ID.
</>
),
placeholder: "123456789", placeholder: "123456789",
error: Boolean(errors.GITHUB_ORGANIZATION_ID), error: Boolean(errors.GITHUB_ORGANIZATION_ID),
required: false, required: false,

View file

@ -3,18 +3,16 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { ThemeProvider, useTheme } from "next-themes"; import { ThemeProvider, useTheme } from "next-themes";
import { SWRConfig } from "swr"; import { SWRConfig } from "swr";
// ui // plane imports
import { ADMIN_BASE_PATH, DEFAULT_SWR_CONFIG } from "@plane/constants"; import { ADMIN_BASE_PATH, DEFAULT_SWR_CONFIG } from "@plane/constants";
import { Toast } from "@plane/ui"; import { Toast } from "@plane/ui";
import { resolveGeneralTheme } from "@plane/utils"; import { resolveGeneralTheme } from "@plane/utils";
// constants
// helpers
// lib // lib
import { InstanceProvider } from "@/lib/instance-provider"; import { InstanceProvider } from "@/lib/instance-provider";
import { StoreProvider } from "@/lib/store-provider"; import { StoreProvider } from "@/lib/store-provider";
import { UserProvider } from "@/lib/user-provider"; import { UserProvider } from "@/lib/user-provider";
// styles // styles
import "./globals.css"; import "@/styles/globals.css";
const ToastWithTheme = () => { const ToastWithTheme = () => {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();

View file

@ -7,7 +7,7 @@ import { LogOut, UserCog2, Palette } from "lucide-react";
import { Menu, Transition } from "@headlessui/react"; import { Menu, Transition } from "@headlessui/react";
// plane internal packages // plane internal packages
import { API_BASE_URL } from "@plane/constants"; import { API_BASE_URL } from "@plane/constants";
import {AuthService } from "@plane/services"; import { AuthService } from "@plane/services";
import { Avatar } from "@plane/ui"; import { Avatar } from "@plane/ui";
import { getFileURL, cn } from "@plane/utils"; import { getFileURL, cn } from "@plane/utils";
// hooks // hooks

View file

@ -67,9 +67,8 @@ export const InstanceHeader: FC = observer(() => {
{breadcrumbItems.length >= 0 && ( {breadcrumbItems.length >= 0 && (
<div> <div>
<Breadcrumbs> <Breadcrumbs>
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.Item
type="text" component={
link={
<BreadcrumbLink <BreadcrumbLink
href="/general/" href="/general/"
label="Settings" label="Settings"
@ -80,10 +79,9 @@ export const InstanceHeader: FC = observer(() => {
{breadcrumbItems.map( {breadcrumbItems.map(
(item) => (item) =>
item.title && ( item.title && (
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.Item
key={item.title} key={item.title}
type="text" component={<BreadcrumbLink href={item.href} label={item.title} />}
link={<BreadcrumbLink href={item.href} label={item.title} />}
/> />
) )
)} )}

View file

@ -1,11 +1,11 @@
import { FC } from "react"; import { FC } from "react";
import { Info, X } from "lucide-react"; import { Info, X } from "lucide-react";
// plane constants // plane constants
import { TAuthErrorInfo } from "@plane/constants"; import { TAdminAuthErrorInfo } from "@plane/constants";
type TAuthBanner = { type TAuthBanner = {
bannerData: TAuthErrorInfo | undefined; bannerData: TAdminAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void; handleBannerData?: (bannerData: TAdminAuthErrorInfo | undefined) => void;
}; };
export const AuthBanner: FC<TAuthBanner> = (props) => { export const AuthBanner: FC<TAuthBanner> = (props) => {

View file

@ -4,7 +4,7 @@ import { FC, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
// plane internal packages // plane internal packages
import { API_BASE_URL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants"; import { API_BASE_URL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { AuthService } from "@plane/services"; import { AuthService } from "@plane/services";
import { Button, Input, Spinner } from "@plane/ui"; import { Button, Input, Spinner } from "@plane/ui";
// components // components
@ -54,7 +54,7 @@ export const InstanceSignInForm: FC = (props) => {
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined); const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [formData, setFormData] = useState<TFormData>(defaultFromData); const [formData, setFormData] = useState<TFormData>(defaultFromData);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined); const [errorInfo, setErrorInfo] = useState<TAdminAuthErrorInfo | undefined>(undefined);
const handleFormChange = (key: keyof TFormData, value: string | boolean) => const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value })); setFormData((prev) => ({ ...prev, [key]: value }));

View file

@ -3,7 +3,7 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { KeyRound, Mails } from "lucide-react"; import { KeyRound, Mails } from "lucide-react";
// plane packages // plane packages
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants"; import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types"; import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
import { resolveGeneralTheme } from "@plane/utils"; import { resolveGeneralTheme } from "@plane/utils";
// components // components
@ -89,7 +89,7 @@ const errorCodeMessages: {
export const authErrorHandler = ( export const authErrorHandler = (
errorCode: EAdminAuthErrorCodes, errorCode: EAdminAuthErrorCodes,
email?: string | undefined email?: string | undefined
): TAuthErrorInfo | undefined => { ): TAdminAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [ const bannerAlertErrorCodes = [
EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST, EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME, EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,

View file

@ -2,7 +2,7 @@ import set from "lodash/set";
import { observable, action, computed, makeObservable, runInAction } from "mobx"; import { observable, action, computed, makeObservable, runInAction } from "mobx";
// plane internal packages // plane internal packages
import { EInstanceStatus, TInstanceStatus } from "@plane/constants"; import { EInstanceStatus, TInstanceStatus } from "@plane/constants";
import {InstanceService} from "@plane/services"; import { InstanceService } from "@plane/services";
import { import {
IInstance, IInstance,
IInstanceAdmin, IInstanceAdmin,

View file

@ -1,7 +1,7 @@
{ {
"name": "admin", "name": "admin",
"description": "Admin UI for Plane", "description": "Admin UI for Plane",
"version": "0.26.1", "version": "0.27.0",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -10,6 +10,7 @@
"build": "next build", "build": "next build",
"preview": "next build && next start", "preview": "next build && next start",
"start": "next start", "start": "next start",
"format": "prettier --write .",
"lint": "eslint . --ext .ts,.tsx", "lint": "eslint . --ext .ts,.tsx",
"lint:errors": "eslint . --ext .ts,.tsx --quiet" "lint:errors": "eslint . --ext .ts,.tsx --quiet"
}, },
@ -17,10 +18,11 @@
"@headlessui/react": "^1.7.19", "@headlessui/react": "^1.7.19",
"@plane/constants": "*", "@plane/constants": "*",
"@plane/hooks": "*", "@plane/hooks": "*",
"@plane/propel": "*",
"@plane/services": "*",
"@plane/types": "*", "@plane/types": "*",
"@plane/ui": "*", "@plane/ui": "*",
"@plane/utils": "*", "@plane/utils": "*",
"@plane/services": "*",
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0", "@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14", "autoprefixer": "10.4.14",
@ -29,7 +31,7 @@
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"mobx": "^6.12.0", "mobx": "^6.12.0",
"mobx-react": "^9.1.1", "mobx-react": "^9.1.1",
"next": "^14.2.29", "next": "14.2.30",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"react": "^18.3.1", "react": "^18.3.1",
@ -48,6 +50,6 @@
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.4", "@types/zxcvbn": "^4.4.4",
"typescript": "5.3.3" "typescript": "5.8.3"
} }
} }

View file

@ -1,8 +1,2 @@
module.exports = { // eslint-disable-next-line @typescript-eslint/no-require-imports
plugins: { module.exports = require("@plane/tailwind-config/postcss.config.js");
"postcss-import": {},
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -1,5 +1,4 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800&display=swap"); @import "@plane/propel/styles/fonts";
@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@48,400,0,0&display=swap");
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@ -60,23 +59,31 @@
--color-border-300: 212, 212, 212; /* strong border- 1 */ --color-border-300: 212, 212, 212; /* strong border- 1 */
--color-border-400: 185, 185, 185; /* strong border- 2 */ --color-border-400: 185, 185, 185; /* strong border- 2 */
--color-shadow-2xs: 0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06), --color-shadow-2xs:
0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06),
0px 1px 2px 0px rgba(23, 23, 23, 0.14); 0px 1px 2px 0px rgba(23, 23, 23, 0.14);
--color-shadow-xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12), --color-shadow-xs:
0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12),
0px 1px 8px -1px rgba(16, 24, 40, 0.1); 0px 1px 8px -1px rgba(16, 24, 40, 0.1);
--color-shadow-sm: 0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02), --color-shadow-sm:
0px 1px 12px 0px rgba(0, 0, 0, 0.12); 0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02), 0px 1px 12px 0px rgba(0, 0, 0, 0.12);
--color-shadow-rg: 0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08), --color-shadow-rg:
0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08),
0px 1px 12px 0px rgba(16, 24, 40, 0.04); 0px 1px 12px 0px rgba(16, 24, 40, 0.04);
--color-shadow-md: 0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), --color-shadow-md:
0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12),
0px 1px 16px 0px rgba(16, 24, 40, 0.12); 0px 1px 16px 0px rgba(16, 24, 40, 0.12);
--color-shadow-lg: 0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12), --color-shadow-lg:
0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12),
0px 1px 24px 0px rgba(16, 24, 40, 0.12); 0px 1px 24px 0px rgba(16, 24, 40, 0.12);
--color-shadow-xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16), --color-shadow-xl:
0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16),
0px 0px 52px 0px rgba(16, 24, 40, 0.16); 0px 0px 52px 0px rgba(16, 24, 40, 0.16);
--color-shadow-2xl: 0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12), --color-shadow-2xl:
0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12),
0px 1px 32px 0px rgba(16, 24, 40, 0.12); 0px 1px 32px 0px rgba(16, 24, 40, 0.12);
--color-shadow-3xl: 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12), --color-shadow-3xl:
0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12),
0px 1px 48px 0px rgba(16, 24, 40, 0.12); 0px 1px 48px 0px rgba(16, 24, 40, 0.12);
--color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), 0px 12px 32px -16px rgba(0, 0, 0, 0.05); --color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), 0px 12px 32px -16px rgba(0, 0, 0, 0.05);

View file

@ -1,13 +1,19 @@
{ {
"extends": "@plane/typescript-config/nextjs.json", "extends": "@plane/typescript-config/nextjs.json",
"compilerOptions": { "compilerOptions": {
"plugins": [{ "name": "next" }], "plugins": [
{
"name": "next"
}
],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["core/*"], "@/*": ["core/*"],
"@/public/*": ["public/*"], "@/public/*": ["public/*"],
"@/plane-admin/*": ["ce/*"] "@/plane-admin/*": ["ce/*"],
} "@/styles/*": ["styles/*"]
},
"strictNullChecks": true
}, },
"include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]

25
apiserver/.coveragerc Normal file
View file

@ -0,0 +1,25 @@
[run]
source = plane
omit =
*/tests/*
*/migrations/*
*/settings/*
*/wsgi.py
*/asgi.py
*/urls.py
manage.py
*/admin.py
*/apps.py
[report]
exclude_lines =
pragma: no cover
def __repr__
if self.debug:
raise NotImplementedError
if __name__ == .__main__.
pass
raise ImportError
[html]
directory = htmlcov

View file

@ -1,7 +1,7 @@
# Backend # Backend
# Debug value for api server use it as 0 for production use # Debug value for api server use it as 0 for production use
DEBUG=0 DEBUG=0
CORS_ALLOWED_ORIGINS="http://localhost" CORS_ALLOWED_ORIGINS="http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3100"
# Database Settings # Database Settings
POSTGRES_USER="plane" POSTGRES_USER="plane"
@ -27,7 +27,7 @@ RABBITMQ_VHOST="plane"
AWS_REGION="" AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key" AWS_ACCESS_KEY_ID="access-key"
AWS_SECRET_ACCESS_KEY="secret-key" AWS_SECRET_ACCESS_KEY="secret-key"
AWS_S3_ENDPOINT_URL="http://plane-minio:9000" AWS_S3_ENDPOINT_URL="http://localhost:9000"
# Changing this requires change in the nginx.conf for uploads if using minio setup # Changing this requires change in the nginx.conf for uploads if using minio setup
AWS_S3_BUCKET_NAME="uploads" AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit # Maximum file upload limit
@ -37,22 +37,31 @@ FILE_SIZE_LIMIT=5242880
DOCKERIZED=1 # deprecated DOCKERIZED=1 # deprecated
# set to 1 If using the pre-configured minio setup # set to 1 If using the pre-configured minio setup
USE_MINIO=1 USE_MINIO=0
# Nginx Configuration # Nginx Configuration
NGINX_PORT=80 NGINX_PORT=80
# Email redirections and minio domain settings # Email redirections and minio domain settings
WEB_URL="http://localhost" WEB_URL="http://localhost:8000"
# Gunicorn Workers # Gunicorn Workers
GUNICORN_WORKERS=2 GUNICORN_WORKERS=2
# Base URLs # Base URLs
ADMIN_BASE_URL= ADMIN_BASE_URL="http://localhost:3001"
SPACE_BASE_URL= ADMIN_BASE_PATH="/god-mode"
APP_BASE_URL=
SPACE_BASE_URL="http://localhost:3002"
SPACE_BASE_PATH="/spaces"
APP_BASE_URL="http://localhost:3000"
APP_BASE_PATH=""
LIVE_BASE_URL="http://localhost:3100"
LIVE_BASE_PATH="/live"
LIVE_SERVER_SECRET_KEY="secret-key"
# Hard delete files after days # Hard delete files after days
HARD_DELETE_AFTER_DAYS=60 HARD_DELETE_AFTER_DAYS=60

View file

@ -1,6 +1,6 @@
{ {
"name": "plane-api", "name": "plane-api",
"version": "0.26.1", "version": "0.27.0",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"private": true, "private": true,
"description": "API server powering Plane's backend" "description": "API server powering Plane's backend"

View file

@ -48,11 +48,6 @@ class CycleSerializer(BaseSerializer):
if not project_id: if not project_id:
raise serializers.ValidationError("Project ID is required") raise serializers.ValidationError("Project ID is required")
is_start_date_end_date_equal = (
True
if str(data.get("start_date")) == str(data.get("end_date"))
else False
)
data["start_date"] = convert_to_utc( data["start_date"] = convert_to_utc(
date=str(data.get("start_date").date()), date=str(data.get("start_date").date()),
project_id=project_id, project_id=project_id,
@ -61,7 +56,6 @@ class CycleSerializer(BaseSerializer):
data["end_date"] = convert_to_utc( data["end_date"] = convert_to_utc(
date=str(data.get("end_date", None).date()), date=str(data.get("end_date", None).date()),
project_id=project_id, project_id=project_id,
is_start_date_end_date_equal=is_start_date_end_date_equal,
) )
return data return data

View file

@ -160,12 +160,15 @@ class IssueSerializer(BaseSerializer):
else: else:
try: try:
# Then assign it to default assignee, if it is a valid assignee # Then assign it to default assignee, if it is a valid assignee
if default_assignee_id is not None and ProjectMember.objects.filter( if (
default_assignee_id is not None
and ProjectMember.objects.filter(
member_id=default_assignee_id, member_id=default_assignee_id,
project_id=project_id, project_id=project_id,
role__gte=15, role__gte=15,
is_active=True is_active=True,
).exists(): ).exists()
):
IssueAssignee.objects.create( IssueAssignee.objects.create(
assignee_id=default_assignee_id, assignee_id=default_assignee_id,
issue=issue, issue=issue,

View file

@ -788,6 +788,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False, issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True, issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
), ),
) )
) )
@ -799,6 +800,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False, issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True, issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
), ),
) )
) )
@ -847,6 +849,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
) )
) )
) )
old_cycle = old_cycle.first()
estimate_type = Project.objects.filter( estimate_type = Project.objects.filter(
workspace__slug=slug, workspace__slug=slug,
@ -966,7 +969,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
) )
estimate_completion_chart = burndown_plot( estimate_completion_chart = burndown_plot(
queryset=old_cycle.first(), queryset=old_cycle,
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type="points", plot_type="points",
@ -1114,7 +1117,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
# Pass the new_cycle queryset to burndown_plot # Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot( completion_chart = burndown_plot(
queryset=old_cycle.first(), queryset=old_cycle,
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type="issues", plot_type="issues",
@ -1126,12 +1129,12 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
).first() ).first()
current_cycle.progress_snapshot = { current_cycle.progress_snapshot = {
"total_issues": old_cycle.first().total_issues, "total_issues": old_cycle.total_issues,
"completed_issues": old_cycle.first().completed_issues, "completed_issues": old_cycle.completed_issues,
"cancelled_issues": old_cycle.first().cancelled_issues, "cancelled_issues": old_cycle.cancelled_issues,
"started_issues": old_cycle.first().started_issues, "started_issues": old_cycle.started_issues,
"unstarted_issues": old_cycle.first().unstarted_issues, "unstarted_issues": old_cycle.unstarted_issues,
"backlog_issues": old_cycle.first().backlog_issues, "backlog_issues": old_cycle.backlog_issues,
"distribution": { "distribution": {
"labels": label_distribution_data, "labels": label_distribution_data,
"assignees": assignee_distribution_data, "assignees": assignee_distribution_data,

View file

@ -58,7 +58,7 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView from .base import BaseAPIView
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.bgtasks.webhook_task import model_activity from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
class WorkspaceIssueAPIEndpoint(BaseAPIView): class WorkspaceIssueAPIEndpoint(BaseAPIView):
""" """
@ -692,6 +692,9 @@ class IssueLinkAPIEndpoint(BaseAPIView):
serializer = IssueLinkSerializer(data=request.data) serializer = IssueLinkSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id) serializer.save(project_id=project_id, issue_id=issue_id)
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
link = IssueLink.objects.get(pk=serializer.data["id"]) link = IssueLink.objects.get(pk=serializer.data["id"])
link.created_by_id = request.data.get("created_by", request.user.id) link.created_by_id = request.data.get("created_by", request.user.id)
@ -719,6 +722,9 @@ class IssueLinkAPIEndpoint(BaseAPIView):
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay( issue_activity.delay(
type="link.activity.updated", type="link.activity.updated",
requested_data=requested_data, requested_data=requested_data,

View file

@ -172,14 +172,14 @@ class ProjectAPIEndpoint(BaseAPIView):
states = [ states = [
{ {
"name": "Backlog", "name": "Backlog",
"color": "#A3A3A3", "color": "#60646C",
"sequence": 15000, "sequence": 15000,
"group": "backlog", "group": "backlog",
"default": True, "default": True,
}, },
{ {
"name": "Todo", "name": "Todo",
"color": "#3A3A3A", "color": "#60646C",
"sequence": 25000, "sequence": 25000,
"group": "unstarted", "group": "unstarted",
}, },
@ -191,13 +191,13 @@ class ProjectAPIEndpoint(BaseAPIView):
}, },
{ {
"name": "Done", "name": "Done",
"color": "#16A34A", "color": "#46A758",
"sequence": 45000, "sequence": 45000,
"group": "completed", "group": "completed",
}, },
{ {
"name": "Cancelled", "name": "Cancelled",
"color": "#EF4444", "color": "#9AA4BC",
"sequence": 55000, "sequence": 55000,
"group": "cancelled", "group": "cancelled",
}, },

View file

@ -39,7 +39,7 @@ from .project import (
ProjectMemberRoleSerializer, ProjectMemberRoleSerializer,
) )
from .state import StateSerializer, StateLiteSerializer from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer from .view import IssueViewSerializer, ViewIssueListSerializer
from .cycle import ( from .cycle import (
CycleSerializer, CycleSerializer,
CycleIssueSerializer, CycleIssueSerializer,
@ -74,6 +74,7 @@ from .issue import (
IssueLinkLiteSerializer, IssueLinkLiteSerializer,
IssueVersionDetailSerializer, IssueVersionDetailSerializer,
IssueDescriptionVersionDetailSerializer, IssueDescriptionVersionDetailSerializer,
IssueListDetailSerializer,
) )
from .module import ( from .module import (

View file

@ -25,11 +25,6 @@ class CycleWriteSerializer(BaseSerializer):
or (self.instance and self.instance.project_id) or (self.instance and self.instance.project_id)
or self.context.get("project_id", None) or self.context.get("project_id", None)
) )
is_start_date_end_date_equal = (
True
if str(data.get("start_date")) == str(data.get("end_date"))
else False
)
data["start_date"] = convert_to_utc( data["start_date"] = convert_to_utc(
date=str(data.get("start_date").date()), date=str(data.get("start_date").date()),
project_id=project_id, project_id=project_id,
@ -38,7 +33,6 @@ class CycleWriteSerializer(BaseSerializer):
data["end_date"] = convert_to_utc( data["end_date"] = convert_to_utc(
date=str(data.get("end_date", None).date()), date=str(data.get("end_date", None).date()),
project_id=project_id, project_id=project_id,
is_start_date_end_date_equal=is_start_date_end_date_equal,
) )
return data return data

View file

@ -53,6 +53,7 @@ def get_entity_model_and_serializer(entity_type):
} }
return entity_map.get(entity_type, (None, None)) return entity_map.get(entity_type, (None, None))
class UserFavoriteSerializer(serializers.ModelSerializer): class UserFavoriteSerializer(serializers.ModelSerializer):
entity_data = serializers.SerializerMethodField() entity_data = serializers.SerializerMethodField()

View file

@ -725,6 +725,110 @@ class IssueSerializer(DynamicBaseSerializer):
read_only_fields = fields read_only_fields = fields
class IssueListDetailSerializer(serializers.Serializer):
def __init__(self, *args, **kwargs):
# Extract expand parameter and store it as instance variable
self.expand = kwargs.pop("expand", []) or []
# Extract fields parameter and store it as instance variable
self.fields = kwargs.pop("fields", []) or []
super().__init__(*args, **kwargs)
def get_module_ids(self, obj):
return [module.module_id for module in obj.issue_module.all()]
def get_label_ids(self, obj):
return [label.label_id for label in obj.label_issue.all()]
def get_assignee_ids(self, obj):
return [assignee.assignee_id for assignee in obj.issue_assignee.all()]
def to_representation(self, instance):
data = {
# Basic fields
"id": instance.id,
"name": instance.name,
"state_id": instance.state_id,
"sort_order": instance.sort_order,
"completed_at": instance.completed_at,
"estimate_point": instance.estimate_point_id,
"priority": instance.priority,
"start_date": instance.start_date,
"target_date": instance.target_date,
"sequence_id": instance.sequence_id,
"project_id": instance.project_id,
"parent_id": instance.parent_id,
"created_at": instance.created_at,
"updated_at": instance.updated_at,
"created_by": instance.created_by_id,
"updated_by": instance.updated_by_id,
"is_draft": instance.is_draft,
"archived_at": instance.archived_at,
# Computed fields
"cycle_id": instance.cycle_id,
"module_ids": self.get_module_ids(instance),
"label_ids": self.get_label_ids(instance),
"assignee_ids": self.get_assignee_ids(instance),
"sub_issues_count": instance.sub_issues_count,
"attachment_count": instance.attachment_count,
"link_count": instance.link_count,
}
# Handle expanded fields only when requested - using direct field access
if self.expand:
if "issue_relation" in self.expand:
relations = []
for relation in instance.issue_relation.all():
related_issue = relation.related_issue
# If the related issue is deleted, skip it
if not related_issue:
continue
# Add the related issue to the relations list
relations.append(
{
"id": related_issue.id,
"project_id": related_issue.project_id,
"sequence_id": related_issue.sequence_id,
"name": related_issue.name,
"relation_type": relation.relation_type,
"state_id": related_issue.state_id,
"priority": related_issue.priority,
"created_by": related_issue.created_by_id,
"created_at": related_issue.created_at,
"updated_at": related_issue.updated_at,
"updated_by": related_issue.updated_by_id,
}
)
data["issue_relation"] = relations
if "issue_related" in self.expand:
related = []
for relation in instance.issue_related.all():
issue = relation.issue
# If the related issue is deleted, skip it
if not issue:
continue
# Add the related issue to the related list
related.append(
{
"id": issue.id,
"project_id": issue.project_id,
"sequence_id": issue.sequence_id,
"name": issue.name,
"relation_type": relation.relation_type,
"state_id": issue.state_id,
"priority": issue.priority,
"created_by": issue.created_by_id,
"created_at": issue.created_at,
"updated_at": issue.updated_at,
"updated_by": issue.updated_by_id,
}
)
data["issue_related"] = related
return data
class IssueLiteSerializer(DynamicBaseSerializer): class IssueLiteSerializer(DynamicBaseSerializer):
class Meta: class Meta:
model = Issue model = Issue

View file

@ -149,9 +149,12 @@ class ProjectMemberAdminSerializer(BaseSerializer):
class ProjectMemberRoleSerializer(DynamicBaseSerializer): class ProjectMemberRoleSerializer(DynamicBaseSerializer):
original_role = serializers.IntegerField(source='role', read_only=True)
class Meta: class Meta:
model = ProjectMember model = ProjectMember
fields = ("id", "role", "member", "project") fields = ("id", "role", "member", "project", "original_role", "created_at")
read_only_fields = ["original_role", "created_at"]
class ProjectMemberInviteSerializer(BaseSerializer): class ProjectMemberInviteSerializer(BaseSerializer):

View file

@ -1,11 +1,13 @@
# Module imports # Module imports
from .base import BaseSerializer from .base import BaseSerializer
from rest_framework import serializers
from plane.db.models import State from plane.db.models import State
class StateSerializer(BaseSerializer): class StateSerializer(BaseSerializer):
order = serializers.FloatField(required=False)
class Meta: class Meta:
model = State model = State
fields = [ fields = [
@ -18,6 +20,7 @@ class StateSerializer(BaseSerializer):
"default", "default",
"description", "description",
"sequence", "sequence",
"order",
] ]
read_only_fields = ["workspace", "project"] read_only_fields = ["workspace", "project"]

View file

@ -3,11 +3,22 @@ from rest_framework import serializers
# Module import # Module import
from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite
from plane.utils.url import contains_url
from .base import BaseSerializer from .base import BaseSerializer
class UserSerializer(BaseSerializer): class UserSerializer(BaseSerializer):
def validate_first_name(self, value):
if contains_url(value):
raise serializers.ValidationError("First name cannot contain a URL.")
return value
def validate_last_name(self, value):
if contains_url(value):
raise serializers.ValidationError("Last name cannot contain a URL.")
return value
class Meta: class Meta:
model = User model = User
# Exclude password field from the serializer # Exclude password field from the serializer
@ -99,11 +110,16 @@ class UserMeSettingsSerializer(BaseSerializer):
workspace_member__member=obj.id, workspace_member__member=obj.id,
workspace_member__is_active=True, workspace_member__is_active=True,
).first() ).first()
logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else ""
return { return {
"last_workspace_id": profile.last_workspace_id, "last_workspace_id": profile.last_workspace_id,
"last_workspace_slug": ( "last_workspace_slug": (
workspace.slug if workspace is not None else "" workspace.slug if workspace is not None else ""
), ),
"last_workspace_name": (
workspace.name if workspace is not None else ""
),
"last_workspace_logo": (logo_asset_url),
"fallback_workspace_id": profile.last_workspace_id, "fallback_workspace_id": profile.last_workspace_id,
"fallback_workspace_slug": ( "fallback_workspace_slug": (
workspace.slug if workspace is not None else "" workspace.slug if workspace is not None else ""

View file

@ -7,6 +7,49 @@ from plane.db.models import IssueView
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
class ViewIssueListSerializer(serializers.Serializer):
def get_assignee_ids(self, instance):
return [assignee.assignee_id for assignee in instance.issue_assignee.all()]
def get_label_ids(self, instance):
return [label.label_id for label in instance.label_issue.all()]
def get_module_ids(self, instance):
return [module.module_id for module in instance.issue_module.all()]
def to_representation(self, instance):
data = {
"id": instance.id,
"name": instance.name,
"state_id": instance.state_id,
"sort_order": instance.sort_order,
"completed_at": instance.completed_at,
"estimate_point": instance.estimate_point_id,
"priority": instance.priority,
"start_date": instance.start_date,
"target_date": instance.target_date,
"sequence_id": instance.sequence_id,
"project_id": instance.project_id,
"parent_id": instance.parent_id,
"cycle_id": instance.cycle_id,
"sub_issues_count": instance.sub_issues_count,
"created_at": instance.created_at,
"updated_at": instance.updated_at,
"created_by": instance.created_by_id,
"updated_by": instance.updated_by_id,
"attachment_count": instance.attachment_count,
"link_count": instance.link_count,
"is_draft": instance.is_draft,
"archived_at": instance.archived_at,
"state__group": instance.state.group if instance.state else None,
"assignee_ids": self.get_assignee_ids(instance),
"label_ids": self.get_label_ids(instance),
"module_ids": self.get_module_ids(instance),
}
return data
class IssueViewSerializer(DynamicBaseSerializer): class IssueViewSerializer(DynamicBaseSerializer):
is_favorite = serializers.BooleanField(read_only=True) is_favorite = serializers.BooleanField(read_only=True)

View file

@ -1,7 +1,5 @@
# Third party imports # Third party imports
from rest_framework import serializers from rest_framework import serializers
from rest_framework import status
from rest_framework.response import Response
# Module imports # Module imports
from .base import BaseSerializer, DynamicBaseSerializer from .base import BaseSerializer, DynamicBaseSerializer
@ -25,10 +23,12 @@ from plane.db.models import (
WorkspaceUserPreference, WorkspaceUserPreference,
) )
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.utils.url import contains_url
# Django imports # Django imports
from django.core.validators import URLValidator from django.core.validators import URLValidator
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
import re
class WorkSpaceSerializer(DynamicBaseSerializer): class WorkSpaceSerializer(DynamicBaseSerializer):
@ -36,10 +36,21 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
logo_url = serializers.CharField(read_only=True) logo_url = serializers.CharField(read_only=True)
role = serializers.IntegerField(read_only=True) role = serializers.IntegerField(read_only=True)
def validate_name(self, value):
# Check if the name contains a URL
if contains_url(value):
raise serializers.ValidationError("Name must not contain URLs")
return value
def validate_slug(self, value): def validate_slug(self, value):
# Check if the slug is restricted # Check if the slug is restricted
if value in RESTRICTED_WORKSPACE_SLUGS: if value in RESTRICTED_WORKSPACE_SLUGS:
raise serializers.ValidationError("Slug is not valid") raise serializers.ValidationError("Slug is not valid")
# Slug should only contain alphanumeric characters, hyphens, and underscores
if not re.match(r"^[a-zA-Z0-9_-]+$", value):
raise serializers.ValidationError(
"Slug can only contain letters, numbers, hyphens (-), and underscores (_)"
)
return value return value
class Meta: class Meta:
@ -148,7 +159,6 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
return value return value
def create(self, validated_data): def create(self, validated_data):
# Filtering the WorkspaceUserLink with the given url to check if the link already exists. # Filtering the WorkspaceUserLink with the given url to check if the link already exists.
@ -157,7 +167,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
workspace_user_link = WorkspaceUserLink.objects.filter( workspace_user_link = WorkspaceUserLink.objects.filter(
url=url, url=url,
workspace_id=validated_data.get("workspace_id"), workspace_id=validated_data.get("workspace_id"),
owner_id=validated_data.get("owner_id") owner_id=validated_data.get("owner_id"),
) )
if workspace_user_link.exists(): if workspace_user_link.exists():
@ -173,9 +183,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
url = validated_data.get("url") url = validated_data.get("url")
workspace_user_link = WorkspaceUserLink.objects.filter( workspace_user_link = WorkspaceUserLink.objects.filter(
url=url, url=url, workspace_id=instance.workspace_id, owner=instance.owner
workspace_id=instance.workspace_id,
owner=instance.owner
) )
if workspace_user_link.exclude(pk=instance.id).exists(): if workspace_user_link.exclude(pk=instance.id).exists():
@ -185,8 +193,10 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
return super().update(instance, validated_data) return super().update(instance, validated_data)
class IssueRecentVisitSerializer(serializers.ModelSerializer): class IssueRecentVisitSerializer(serializers.ModelSerializer):
project_identifier = serializers.SerializerMethodField() project_identifier = serializers.SerializerMethodField()
assignees = serializers.SerializerMethodField()
class Meta: class Meta:
model = Issue model = Issue
@ -204,9 +214,15 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
def get_project_identifier(self, obj): def get_project_identifier(self, obj):
project = obj.project project = obj.project
return project.identifier if project else None return project.identifier if project else None
def get_assignees(self, obj):
return list(
obj.assignees.filter(issue_assignee__deleted_at__isnull=True).values_list(
"id", flat=True
)
)
class ProjectRecentVisitSerializer(serializers.ModelSerializer): class ProjectRecentVisitSerializer(serializers.ModelSerializer):
project_members = serializers.SerializerMethodField() project_members = serializers.SerializerMethodField()

View file

@ -6,8 +6,14 @@ from plane.app.views import (
AnalyticViewViewset, AnalyticViewViewset,
SavedAnalyticEndpoint, SavedAnalyticEndpoint,
ExportAnalyticsEndpoint, ExportAnalyticsEndpoint,
AdvanceAnalyticsEndpoint,
AdvanceAnalyticsStatsEndpoint,
AdvanceAnalyticsChartEndpoint,
DefaultAnalyticsEndpoint, DefaultAnalyticsEndpoint,
ProjectStatsEndpoint, ProjectStatsEndpoint,
ProjectAdvanceAnalyticsEndpoint,
ProjectAdvanceAnalyticsStatsEndpoint,
ProjectAdvanceAnalyticsChartEndpoint,
) )
@ -49,4 +55,34 @@ urlpatterns = [
ProjectStatsEndpoint.as_view(), ProjectStatsEndpoint.as_view(),
name="project-analytics", name="project-analytics",
), ),
path(
"workspaces/<str:slug>/advance-analytics/",
AdvanceAnalyticsEndpoint.as_view(),
name="advance-analytics",
),
path(
"workspaces/<str:slug>/advance-analytics-stats/",
AdvanceAnalyticsStatsEndpoint.as_view(),
name="advance-analytics-stats",
),
path(
"workspaces/<str:slug>/advance-analytics-charts/",
AdvanceAnalyticsChartEndpoint.as_view(),
name="advance-analytics-chart",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/advance-analytics/",
ProjectAdvanceAnalyticsEndpoint.as_view(),
name="project-advance-analytics",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/advance-analytics-stats/",
ProjectAdvanceAnalyticsStatsEndpoint.as_view(),
name="project-advance-analytics-stats",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/advance-analytics-charts/",
ProjectAdvanceAnalyticsChartEndpoint.as_view(),
name="project-advance-analytics-chart",
),
] ]

View file

@ -4,14 +4,14 @@ from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
urlpatterns = [ urlpatterns = [
# API Tokens # API Tokens
path( path(
"workspaces/<str:slug>/api-tokens/", "users/api-tokens/",
ApiTokenEndpoint.as_view(), ApiTokenEndpoint.as_view(),
name="api-tokens", name="api-tokens",
), ),
path( path(
"workspaces/<str:slug>/api-tokens/<uuid:pk>/", "users/api-tokens/<uuid:pk>/",
ApiTokenEndpoint.as_view(), ApiTokenEndpoint.as_view(),
name="api-tokens", name="api-tokens-details",
), ),
path( path(
"workspaces/<str:slug>/service-api-tokens/", "workspaces/<str:slug>/service-api-tokens/",

View file

@ -12,6 +12,9 @@ from plane.app.views import (
AssetRestoreEndpoint, AssetRestoreEndpoint,
ProjectAssetEndpoint, ProjectAssetEndpoint,
ProjectBulkAssetEndpoint, ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
) )
@ -81,5 +84,21 @@ urlpatterns = [
path( path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/<uuid:entity_id>/bulk/", "assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/<uuid:entity_id>/bulk/",
ProjectBulkAssetEndpoint.as_view(), ProjectBulkAssetEndpoint.as_view(),
name="bulk-asset-update",
),
path(
"assets/v2/workspaces/<str:slug>/check/<uuid:asset_id>/",
AssetCheckEndpoint.as_view(),
name="asset-check",
),
path(
"assets/v2/workspaces/<str:slug>/download/<uuid:asset_id>/",
WorkspaceAssetDownloadEndpoint.as_view(),
name="workspace-asset-download",
),
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/download/<uuid:asset_id>/",
ProjectAssetDownloadEndpoint.as_view(),
name="project-asset-download",
), ),
] ]

View file

@ -106,6 +106,9 @@ from .asset.v2 import (
AssetRestoreEndpoint, AssetRestoreEndpoint,
ProjectAssetEndpoint, ProjectAssetEndpoint,
ProjectBulkAssetEndpoint, ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
) )
from .issue.base import ( from .issue.base import (
IssueListEndpoint, IssueListEndpoint,
@ -199,6 +202,18 @@ from .analytic.base import (
ProjectStatsEndpoint, ProjectStatsEndpoint,
) )
from .analytic.advance import (
AdvanceAnalyticsEndpoint,
AdvanceAnalyticsStatsEndpoint,
AdvanceAnalyticsChartEndpoint,
)
from .analytic.project_analytics import (
ProjectAdvanceAnalyticsEndpoint,
ProjectAdvanceAnalyticsStatsEndpoint,
ProjectAdvanceAnalyticsChartEndpoint,
)
from .notification.base import ( from .notification.base import (
NotificationViewSet, NotificationViewSet,
UnreadNotificationEndpoint, UnreadNotificationEndpoint,

View file

@ -0,0 +1,366 @@
from rest_framework.response import Response
from rest_framework import status
from typing import Dict, List, Any
from django.db.models import QuerySet, Q, Count
from django.http import HttpRequest
from django.db.models.functions import TruncMonth
from django.utils import timezone
from plane.app.views.base import BaseAPIView
from plane.app.permissions import ROLE, allow_permission
from plane.db.models import (
WorkspaceMember,
Project,
Issue,
Cycle,
Module,
IssueView,
ProjectPage,
Workspace,
CycleIssue,
ModuleIssue,
ProjectMember,
)
from plane.utils.build_chart import build_analytics_chart
from plane.utils.date_utils import (
get_analytics_filters,
)
class AdvanceAnalyticsBaseView(BaseAPIView):
def initialize_workspace(self, slug: str, type: str) -> None:
self._workspace_slug = slug
self.filters = get_analytics_filters(
slug=slug,
type=type,
user=self.request.user,
date_filter=self.request.GET.get("date_filter", None),
project_ids=self.request.GET.get("project_ids", None),
)
class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
def get_filtered_count() -> int:
if self.filters["analytics_date_range"]:
return queryset.filter(
created_at__gte=self.filters["analytics_date_range"]["current"][
"gte"
],
created_at__lte=self.filters["analytics_date_range"]["current"][
"lte"
],
).count()
return queryset.count()
def get_previous_count() -> int:
if self.filters["analytics_date_range"] and self.filters[
"analytics_date_range"
].get("previous"):
return queryset.filter(
created_at__gte=self.filters["analytics_date_range"]["previous"][
"gte"
],
created_at__lte=self.filters["analytics_date_range"]["previous"][
"lte"
],
).count()
return 0
return {
"count": get_filtered_count(),
# "filter_count": get_previous_count(),
}
def get_overview_data(self) -> Dict[str, Dict[str, int]]:
members_query = WorkspaceMember.objects.filter(
workspace__slug=self._workspace_slug, is_active=True
)
if self.request.GET.get("project_ids", None):
project_ids = self.request.GET.get("project_ids", None)
project_ids = [str(project_id) for project_id in project_ids.split(",")]
members_query = ProjectMember.objects.filter(
project_id__in=project_ids, is_active=True
)
return {
"total_users": self.get_filtered_counts(members_query),
"total_admins": self.get_filtered_counts(
members_query.filter(role=ROLE.ADMIN.value)
),
"total_members": self.get_filtered_counts(
members_query.filter(role=ROLE.MEMBER.value)
),
"total_guests": self.get_filtered_counts(
members_query.filter(role=ROLE.GUEST.value)
),
"total_projects": self.get_filtered_counts(
Project.objects.filter(**self.filters["project_filters"])
),
"total_work_items": self.get_filtered_counts(
Issue.issue_objects.filter(**self.filters["base_filters"])
),
"total_cycles": self.get_filtered_counts(
Cycle.objects.filter(**self.filters["base_filters"])
),
"total_intake": self.get_filtered_counts(
Issue.objects.filter(**self.filters["base_filters"]).filter(
issue_intake__status__in=["-2", "0"]
)
),
}
def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
return {
"total_work_items": self.get_filtered_counts(base_queryset),
"started_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="started")
),
"backlog_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="backlog")
),
"un_started_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="unstarted")
),
"completed_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="completed")
),
}
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request: HttpRequest, slug: str) -> Response:
self.initialize_workspace(slug, type="analytics")
tab = request.GET.get("tab", "overview")
if tab == "overview":
return Response(
self.get_overview_data(),
status=status.HTTP_200_OK,
)
elif tab == "work-items":
return Response(
self.get_work_items_stats(),
status=status.HTTP_200_OK,
)
return Response({"message": "Invalid tab"}, status=status.HTTP_400_BAD_REQUEST)
class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView):
def get_project_issues_stats(self) -> QuerySet:
# Get the base queryset with workspace and project filters
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
base_queryset = base_queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
return (
base_queryset.values("project_id", "project__name").annotate(
cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
completed_work_items=Count("id", filter=Q(state__group="completed")),
backlog_work_items=Count("id", filter=Q(state__group="backlog")),
un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
started_work_items=Count("id", filter=Q(state__group="started")),
)
.order_by("project_id")
)
def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
return (
base_queryset
.values("project_id", "project__name")
.annotate(
cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
completed_work_items=Count("id", filter=Q(state__group="completed")),
backlog_work_items=Count("id", filter=Q(state__group="backlog")),
un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
started_work_items=Count("id", filter=Q(state__group="started")),
)
.order_by("project_id")
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request: HttpRequest, slug: str) -> Response:
self.initialize_workspace(slug, type="chart")
type = request.GET.get("type", "work-items")
if type == "work-items":
return Response(
self.get_work_items_stats(),
status=status.HTTP_200_OK,
)
return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
def project_chart(self) -> List[Dict[str, Any]]:
# Get the base queryset with workspace and project filters
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
date_filter = {}
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
date_filter = {
"created_at__date__gte": start_date,
"created_at__date__lte": end_date,
}
total_work_items = base_queryset.filter(**date_filter).count()
total_cycles = Cycle.objects.filter(
**self.filters["base_filters"], **date_filter
).count()
total_modules = Module.objects.filter(
**self.filters["base_filters"], **date_filter
).count()
total_intake = Issue.objects.filter(
issue_intake__isnull=False, **self.filters["base_filters"], **date_filter
).count()
total_members = WorkspaceMember.objects.filter(
workspace__slug=self._workspace_slug, is_active=True, **date_filter
).count()
total_pages = ProjectPage.objects.filter(
**self.filters["base_filters"], **date_filter
).count()
total_views = IssueView.objects.filter(
**self.filters["base_filters"], **date_filter
).count()
data = {
"work_items": total_work_items,
"cycles": total_cycles,
"modules": total_modules,
"intake": total_intake,
"members": total_members,
"pages": total_pages,
"views": total_views,
}
return [
{
"key": key,
"name": key.replace("_", " ").title(),
"count": value or 0,
}
for key, value in data.items()
]
def work_item_completion_chart(self) -> Dict[str, Any]:
# Get the base queryset
queryset = (
Issue.issue_objects.filter(**self.filters["base_filters"])
.select_related("workspace", "state", "parent")
.prefetch_related(
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
)
)
workspace = Workspace.objects.get(slug=self._workspace_slug)
start_date = workspace.created_at.date().replace(day=1)
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
queryset = queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
# Annotate by month and count
monthly_stats = (
queryset.annotate(month=TruncMonth("created_at"))
.values("month")
.annotate(
created_count=Count("id"),
completed_count=Count("id", filter=Q(state__group="completed")),
)
.order_by("month")
)
# Create dictionary of month -> counts
stats_dict = {
stat["month"].strftime("%Y-%m-%d"): {
"created_count": stat["created_count"],
"completed_count": stat["completed_count"],
}
for stat in monthly_stats
}
# Generate monthly data (ensure months with 0 count are included)
data = []
# include the current date at the end
end_date = timezone.now().date()
last_month = end_date.replace(day=1)
current_month = start_date
while current_month <= last_month:
date_str = current_month.strftime("%Y-%m-%d")
stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0})
data.append(
{
"key": date_str,
"name": date_str,
"count": stats["created_count"],
"completed_issues": stats["completed_count"],
"created_issues": stats["created_count"],
}
)
# Move to next month
if current_month.month == 12:
current_month = current_month.replace(
year=current_month.year + 1, month=1
)
else:
current_month = current_month.replace(month=current_month.month + 1)
schema = {
"completed_issues": "completed_issues",
"created_issues": "created_issues",
}
return {"data": data, "schema": schema}
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request: HttpRequest, slug: str) -> Response:
self.initialize_workspace(slug, type="chart")
type = request.GET.get("type", "projects")
group_by = request.GET.get("group_by", None)
x_axis = request.GET.get("x_axis", "PRIORITY")
if type == "projects":
return Response(self.project_chart(), status=status.HTTP_200_OK)
elif type == "custom-work-items":
queryset = (
Issue.issue_objects.filter(**self.filters["base_filters"])
.select_related("workspace", "state", "parent")
.prefetch_related(
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
)
)
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
queryset = queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
return Response(
build_analytics_chart(queryset, x_axis, group_by),
status=status.HTTP_200_OK,
)
elif type == "work-items":
return Response(
self.work_item_completion_chart(),
status=status.HTTP_200_OK,
)
return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)

View file

@ -0,0 +1,421 @@
from rest_framework.response import Response
from rest_framework import status
from typing import Dict, Any
from django.db.models import QuerySet, Q, Count
from django.http import HttpRequest
from django.db.models.functions import TruncMonth
from django.utils import timezone
from datetime import timedelta
from plane.app.views.base import BaseAPIView
from plane.app.permissions import ROLE, allow_permission
from plane.db.models import (
Project,
Issue,
Cycle,
Module,
CycleIssue,
ModuleIssue,
)
from django.db import models
from django.db.models import F, Case, When, Value
from django.db.models.functions import Concat
from plane.utils.build_chart import build_analytics_chart
from plane.utils.date_utils import (
get_analytics_filters,
)
class ProjectAdvanceAnalyticsBaseView(BaseAPIView):
def initialize_workspace(self, slug: str, type: str) -> None:
self._workspace_slug = slug
self.filters = get_analytics_filters(
slug=slug,
type=type,
user=self.request.user,
date_filter=self.request.GET.get("date_filter", None),
project_ids=self.request.GET.get("project_ids", None),
)
class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
def get_filtered_count() -> int:
if self.filters["analytics_date_range"]:
return queryset.filter(
created_at__gte=self.filters["analytics_date_range"]["current"][
"gte"
],
created_at__lte=self.filters["analytics_date_range"]["current"][
"lte"
],
).count()
return queryset.count()
return {
"count": get_filtered_count(),
}
def get_work_items_stats(
self, project_id, cycle_id=None, module_id=None
) -> Dict[str, Dict[str, int]]:
"""
Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
"""
base_queryset = None
if cycle_id is not None:
cycle_issues = CycleIssue.objects.filter(
**self.filters["base_filters"], cycle_id=cycle_id
).values_list("issue_id", flat=True)
base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
elif module_id is not None:
module_issues = ModuleIssue.objects.filter(
**self.filters["base_filters"], module_id=module_id
).values_list("issue_id", flat=True)
base_queryset = Issue.issue_objects.filter(id__in=module_issues)
else:
base_queryset = Issue.issue_objects.filter(
**self.filters["base_filters"], project_id=project_id
)
return {
"total_work_items": self.get_filtered_counts(base_queryset),
"started_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="started")
),
"backlog_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="backlog")
),
"un_started_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="unstarted")
),
"completed_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="completed")
),
}
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
self.initialize_workspace(slug, type="analytics")
# Optionally accept cycle_id or module_id as query params
cycle_id = request.GET.get("cycle_id", None)
module_id = request.GET.get("module_id", None)
return Response(
self.get_work_items_stats(
cycle_id=cycle_id, module_id=module_id, project_id=project_id
),
status=status.HTTP_200_OK,
)
class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
def get_project_issues_stats(self) -> QuerySet:
# Get the base queryset with workspace and project filters
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
base_queryset = base_queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
return (
base_queryset.values("project_id", "project__name")
.annotate(
cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
completed_work_items=Count("id", filter=Q(state__group="completed")),
backlog_work_items=Count("id", filter=Q(state__group="backlog")),
un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
started_work_items=Count("id", filter=Q(state__group="started")),
)
.order_by("project_id")
)
def get_work_items_stats(
self, project_id, cycle_id=None, module_id=None
) -> Dict[str, Dict[str, int]]:
base_queryset = None
if cycle_id is not None:
cycle_issues = CycleIssue.objects.filter(
**self.filters["base_filters"], cycle_id=cycle_id
).values_list("issue_id", flat=True)
base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
elif module_id is not None:
module_issues = ModuleIssue.objects.filter(
**self.filters["base_filters"], module_id=module_id
).values_list("issue_id", flat=True)
base_queryset = Issue.issue_objects.filter(id__in=module_issues)
else:
base_queryset = Issue.issue_objects.filter(
**self.filters["base_filters"], project_id=project_id
)
return (
base_queryset.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True, then="assignees__avatar"
),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(
cancelled_work_items=Count(
"id", filter=Q(state__group="cancelled"), distinct=True
),
completed_work_items=Count(
"id", filter=Q(state__group="completed"), distinct=True
),
backlog_work_items=Count(
"id", filter=Q(state__group="backlog"), distinct=True
),
un_started_work_items=Count(
"id", filter=Q(state__group="unstarted"), distinct=True
),
started_work_items=Count(
"id", filter=Q(state__group="started"), distinct=True
),
)
.order_by("display_name")
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
self.initialize_workspace(slug, type="chart")
type = request.GET.get("type", "work-items")
if type == "work-items":
# Optionally accept cycle_id or module_id as query params
cycle_id = request.GET.get("cycle_id", None)
module_id = request.GET.get("module_id", None)
return Response(
self.get_work_items_stats(
project_id=project_id, cycle_id=cycle_id, module_id=module_id
),
status=status.HTTP_200_OK,
)
return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
def work_item_completion_chart(
self, project_id, cycle_id=None, module_id=None
) -> Dict[str, Any]:
# Get the base queryset
queryset = (
Issue.issue_objects.filter(**self.filters["base_filters"])
.filter(project_id=project_id)
.select_related("workspace", "state", "parent")
.prefetch_related(
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
)
)
if cycle_id is not None:
cycle_issues = CycleIssue.objects.filter(
**self.filters["base_filters"], cycle_id=cycle_id
).values_list("issue_id", flat=True)
cycle = Cycle.objects.filter(id=cycle_id).first()
if cycle and cycle.start_date:
start_date = cycle.start_date.date()
end_date = cycle.end_date.date()
else:
return {"data": [], "schema": {}}
queryset = cycle_issues
elif module_id is not None:
module_issues = ModuleIssue.objects.filter(
**self.filters["base_filters"], module_id=module_id
).values_list("issue_id", flat=True)
module = Module.objects.filter(id=module_id).first()
if module and module.start_date:
start_date = module.start_date
end_date = module.target_date
else:
return {"data": [], "schema": {}}
queryset = module_issues
else:
project = Project.objects.filter(id=project_id).first()
if project.created_at:
start_date = project.created_at.date().replace(day=1)
else:
return {"data": [], "schema": {}}
if cycle_id or module_id:
# Get daily stats with optimized query
daily_stats = (
queryset.values("created_at__date")
.annotate(
created_count=Count("id"),
completed_count=Count(
"id", filter=Q(issue__state__group="completed")
),
)
.order_by("created_at__date")
)
# Create a dictionary of existing stats with summed counts
stats_dict = {
stat["created_at__date"].strftime("%Y-%m-%d"): {
"created_count": stat["created_count"],
"completed_count": stat["completed_count"],
}
for stat in daily_stats
}
# Generate data for all days in the range
data = []
current_date = start_date
while current_date <= end_date:
date_str = current_date.strftime("%Y-%m-%d")
stats = stats_dict.get(
date_str, {"created_count": 0, "completed_count": 0}
)
data.append(
{
"key": date_str,
"name": date_str,
"count": stats["created_count"] + stats["completed_count"],
"completed_issues": stats["completed_count"],
"created_issues": stats["created_count"],
}
)
current_date += timedelta(days=1)
else:
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
queryset = queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
# Annotate by month and count
monthly_stats = (
queryset.annotate(month=TruncMonth("created_at"))
.values("month")
.annotate(
created_count=Count("id"),
completed_count=Count("id", filter=Q(state__group="completed")),
)
.order_by("month")
)
# Create dictionary of month -> counts
stats_dict = {
stat["month"].strftime("%Y-%m-%d"): {
"created_count": stat["created_count"],
"completed_count": stat["completed_count"],
}
for stat in monthly_stats
}
# Generate monthly data (ensure months with 0 count are included)
data = []
# include the current date at the end
end_date = timezone.now().date()
last_month = end_date.replace(day=1)
current_month = start_date
while current_month <= last_month:
date_str = current_month.strftime("%Y-%m-%d")
stats = stats_dict.get(
date_str, {"created_count": 0, "completed_count": 0}
)
data.append(
{
"key": date_str,
"name": date_str,
"count": stats["created_count"],
"completed_issues": stats["completed_count"],
"created_issues": stats["created_count"],
}
)
# Move to next month
if current_month.month == 12:
current_month = current_month.replace(
year=current_month.year + 1, month=1
)
else:
current_month = current_month.replace(month=current_month.month + 1)
schema = {
"completed_issues": "completed_issues",
"created_issues": "created_issues",
}
return {"data": data, "schema": schema}
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
self.initialize_workspace(slug, type="chart")
type = request.GET.get("type", "projects")
group_by = request.GET.get("group_by", None)
x_axis = request.GET.get("x_axis", "PRIORITY")
cycle_id = request.GET.get("cycle_id", None)
module_id = request.GET.get("module_id", None)
if type == "custom-work-items":
queryset = (
Issue.issue_objects.filter(**self.filters["base_filters"])
.filter(project_id=project_id)
.select_related("workspace", "state", "parent")
.prefetch_related(
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
)
)
# Apply cycle/module filters if present
if cycle_id is not None:
cycle_issues = CycleIssue.objects.filter(
**self.filters["base_filters"], cycle_id=cycle_id
).values_list("issue_id", flat=True)
queryset = queryset.filter(id__in=cycle_issues)
elif module_id is not None:
module_issues = ModuleIssue.objects.filter(
**self.filters["base_filters"], module_id=module_id
).values_list("issue_id", flat=True)
queryset = queryset.filter(id__in=module_issues)
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
queryset = queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
return Response(
build_analytics_chart(queryset, x_axis, group_by),
status=status.HTTP_200_OK,
)
elif type == "work-items":
# Optionally accept cycle_id or module_id as query params
cycle_id = request.GET.get("cycle_id", None)
module_id = request.GET.get("module_id", None)
return Response(
self.work_item_completion_chart(
project_id=project_id, cycle_id=cycle_id, module_id=module_id
),
status=status.HTTP_200_OK,
)
return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)

View file

@ -1,24 +1,23 @@
# Python import # Python import
from uuid import uuid4 from uuid import uuid4
from typing import Optional
# Third party # Third party
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework import status from rest_framework import status
# Module import # Module import
from .base import BaseAPIView from .base import BaseAPIView
from plane.db.models import APIToken, Workspace from plane.db.models import APIToken, Workspace
from plane.app.serializers import APITokenSerializer, APITokenReadSerializer from plane.app.serializers import APITokenSerializer, APITokenReadSerializer
from plane.app.permissions import WorkspaceOwnerPermission from plane.app.permissions import WorkspaceEntityPermission
class ApiTokenEndpoint(BaseAPIView): class ApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceOwnerPermission] def post(self, request: Request) -> Response:
def post(self, request, slug):
label = request.data.get("label", str(uuid4().hex)) label = request.data.get("label", str(uuid4().hex))
description = request.data.get("description", "") description = request.data.get("description", "")
workspace = Workspace.objects.get(slug=slug)
expired_at = request.data.get("expired_at", None) expired_at = request.data.get("expired_at", None)
# Check the user type # Check the user type
@ -28,7 +27,6 @@ class ApiTokenEndpoint(BaseAPIView):
label=label, label=label,
description=description, description=description,
user=request.user, user=request.user,
workspace=workspace,
user_type=user_type, user_type=user_type,
expired_at=expired_at, expired_at=expired_at,
) )
@ -37,29 +35,23 @@ class ApiTokenEndpoint(BaseAPIView):
# Token will be only visible while creating # Token will be only visible while creating
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug, pk=None): def get(self, request: Request, pk: Optional[str] = None) -> Response:
if pk is None: if pk is None:
api_tokens = APIToken.objects.filter( api_tokens = APIToken.objects.filter(user=request.user, is_service=False)
user=request.user, workspace__slug=slug, is_service=False
)
serializer = APITokenReadSerializer(api_tokens, many=True) serializer = APITokenReadSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
else: else:
api_tokens = APIToken.objects.get( api_tokens = APIToken.objects.get(user=request.user, pk=pk)
user=request.user, workspace__slug=slug, pk=pk
)
serializer = APITokenReadSerializer(api_tokens) serializer = APITokenReadSerializer(api_tokens)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def delete(self, request, slug, pk): def delete(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get( api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
workspace__slug=slug, user=request.user, pk=pk, is_service=False
)
api_token.delete() api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, pk): def patch(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(workspace__slug=slug, user=request.user, pk=pk) api_token = APIToken.objects.get(user=request.user, pk=pk)
serializer = APITokenSerializer(api_token, data=request.data, partial=True) serializer = APITokenSerializer(api_token, data=request.data, partial=True)
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
@ -68,9 +60,9 @@ class ApiTokenEndpoint(BaseAPIView):
class ServiceApiTokenEndpoint(BaseAPIView): class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceOwnerPermission] permission_classes = [WorkspaceEntityPermission]
def post(self, request, slug): def post(self, request: Request, slug: str) -> Response:
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
api_token = APIToken.objects.filter( api_token = APIToken.objects.filter(

View file

@ -707,3 +707,67 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
pass pass
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
class AssetCheckEndpoint(BaseAPIView):
"""Endpoint to check if an asset exists."""
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug, asset_id):
asset = FileAsset.all_objects.filter(
id=asset_id, workspace__slug=slug, deleted_at__isnull=True
).exists()
return Response({"exists": asset}, status=status.HTTP_200_OK)
class WorkspaceAssetDownloadEndpoint(BaseAPIView):
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug, asset_id):
try:
asset = FileAsset.objects.get(
id=asset_id,
workspace__slug=slug,
is_uploaded=True,
)
except FileAsset.DoesNotExist:
return Response(
{"error": "The requested asset could not be found."},
status=status.HTTP_404_NOT_FOUND,
)
storage = S3Storage(request=request)
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition=f"attachment; filename={asset.asset.name}",
)
return HttpResponseRedirect(signed_url)
class ProjectAssetDownloadEndpoint(BaseAPIView):
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")
def get(self, request, slug, project_id, asset_id):
try:
asset = FileAsset.objects.get(
id=asset_id,
workspace__slug=slug,
project_id=project_id,
is_uploaded=True,
)
except FileAsset.DoesNotExist:
return Response(
{"error": "The requested asset could not be found."},
status=status.HTTP_404_NOT_FOUND,
)
storage = S3Storage(request=request)
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition=f"attachment; filename={asset.asset.name}",
)
return HttpResponseRedirect(signed_url)

View file

@ -117,6 +117,7 @@ class CycleViewSet(BaseViewSet):
issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False, issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True, issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
), ),
) )
) )
@ -129,6 +130,7 @@ class CycleViewSet(BaseViewSet):
issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False, issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True, issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
), ),
) )
) )
@ -141,6 +143,7 @@ class CycleViewSet(BaseViewSet):
issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False, issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True, issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
), ),
) )
) )
@ -266,9 +269,7 @@ class CycleViewSet(BaseViewSet):
"created_by", "created_by",
) )
datetime_fields = ["start_date", "end_date"] datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter( data = user_timezone_converter(data, datetime_fields, project_timezone)
data, datetime_fields, project_timezone
)
return Response(data, status=status.HTTP_200_OK) return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@ -415,9 +416,7 @@ class CycleViewSet(BaseViewSet):
project_timezone = project.timezone project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"] datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter( cycle = user_timezone_converter(cycle, datetime_fields, project_timezone)
cycle, datetime_fields, project_timezone
)
# Send the model activity # Send the model activity
model_activity.delay( model_activity.delay(
@ -574,16 +573,12 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
is_start_date_end_date_equal = (
True if str(start_date) == str(end_date) else False
)
start_date = convert_to_utc( start_date = convert_to_utc(
date=str(start_date), project_id=project_id, is_start_date=True date=str(start_date), project_id=project_id, is_start_date=True
) )
end_date = convert_to_utc( end_date = convert_to_utc(
date=str(end_date), date=str(end_date),
project_id=project_id, project_id=project_id,
is_start_date_end_date_equal=is_start_date_end_date_equal,
) )
# Check if any cycle intersects in the given interval # Check if any cycle intersects in the given interval
@ -668,6 +663,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False, issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True, issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
), ),
) )
) )
@ -732,6 +728,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
) )
) )
) )
old_cycle = old_cycle.first()
estimate_type = Project.objects.filter( estimate_type = Project.objects.filter(
workspace__slug=slug, workspace__slug=slug,
@ -850,7 +847,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
) )
estimate_completion_chart = burndown_plot( estimate_completion_chart = burndown_plot(
queryset=old_cycle.first(), queryset=old_cycle,
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type="points", plot_type="points",
@ -997,7 +994,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
# Pass the new_cycle queryset to burndown_plot # Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot( completion_chart = burndown_plot(
queryset=old_cycle.first(), queryset=old_cycle,
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type="issues", plot_type="issues",
@ -1009,12 +1006,12 @@ class TransferCycleIssueEndpoint(BaseAPIView):
).first() ).first()
current_cycle.progress_snapshot = { current_cycle.progress_snapshot = {
"total_issues": old_cycle.first().total_issues, "total_issues": old_cycle.total_issues,
"completed_issues": old_cycle.first().completed_issues, "completed_issues": old_cycle.completed_issues,
"cancelled_issues": old_cycle.first().cancelled_issues, "cancelled_issues": old_cycle.cancelled_issues,
"started_issues": old_cycle.first().started_issues, "started_issues": old_cycle.started_issues,
"unstarted_issues": old_cycle.first().unstarted_issues, "unstarted_issues": old_cycle.unstarted_issues,
"backlog_issues": old_cycle.first().backlog_issues, "backlog_issues": old_cycle.backlog_issues,
"distribution": { "distribution": {
"labels": label_distribution_data, "labels": label_distribution_data,
"assignees": assignee_distribution_data, "assignees": assignee_distribution_data,
@ -1122,6 +1119,13 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
class CycleProgressEndpoint(BaseAPIView): class CycleProgressEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, cycle_id): def get(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, id=cycle_id
).first()
if not cycle:
return Response(
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
)
aggregate_estimates = ( aggregate_estimates = (
Issue.issue_objects.filter( Issue.issue_objects.filter(
estimate_point__estimate__type="points", estimate_point__estimate__type="points",
@ -1172,7 +1176,14 @@ class CycleProgressEndpoint(BaseAPIView):
), ),
) )
) )
if cycle.progress_snapshot:
backlog_issues = cycle.progress_snapshot.get("backlog_issues", 0)
unstarted_issues = cycle.progress_snapshot.get("unstarted_issues", 0)
started_issues = cycle.progress_snapshot.get("started_issues", 0)
cancelled_issues = cycle.progress_snapshot.get("cancelled_issues", 0)
completed_issues = cycle.progress_snapshot.get("completed_issues", 0)
total_issues = cycle.progress_snapshot.get("total_issues", 0)
else:
backlog_issues = Issue.issue_objects.filter( backlog_issues = Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id, issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True, issue_cycle__deleted_at__isnull=True,
@ -1279,6 +1290,25 @@ class CycleAnalyticsEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# this will tell whether the issues were transferred to the new cycle
"""
if the issues were transferred to the new cycle, then the progress_snapshot will be present
return the progress_snapshot data in the analytics for each date
else issues were not transferred to the new cycle then generate the stats from the cycle isssue bridge tables
"""
if cycle.progress_snapshot:
distribution = cycle.progress_snapshot.get("distribution", {})
return Response(
{
"labels": distribution.get("labels", []),
"assignees": distribution.get("assignees", []),
"completion_chart": distribution.get("completion_chart", {}),
},
status=status.HTTP_200_OK,
)
estimate_type = Project.objects.filter( estimate_type = Project.objects.filter(
workspace__slug=slug, workspace__slug=slug,
pk=project_id, pk=project_id,

View file

@ -29,6 +29,7 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina
from plane.app.permissions import allow_permission, ROLE from plane.app.permissions import allow_permission, ROLE
from plane.utils.host import base_host from plane.utils.host import base_host
class CycleIssueViewSet(BaseViewSet): class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer serializer_class = CycleIssueSerializer
model = CycleIssue model = CycleIssue

View file

@ -11,8 +11,7 @@ from rest_framework.response import Response
# Module import # Module import
from plane.app.permissions import ROLE, allow_permission from plane.app.permissions import ROLE, allow_permission
from plane.app.serializers import (ProjectLiteSerializer, from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
WorkspaceLiteSerializer)
from plane.db.models import Project, Workspace from plane.db.models import Project, Workspace
from plane.license.utils.instance_value import get_configuration_value from plane.license.utils.instance_value import get_configuration_value
from plane.utils.exception_logger import log_exception from plane.utils.exception_logger import log_exception
@ -22,6 +21,7 @@ from ..base import BaseAPIView
class LLMProvider: class LLMProvider:
"""Base class for LLM provider configurations""" """Base class for LLM provider configurations"""
name: str = "" name: str = ""
models: List[str] = [] models: List[str] = []
default_model: str = "" default_model: str = ""
@ -34,11 +34,13 @@ class LLMProvider:
"default_model": cls.default_model, "default_model": cls.default_model,
} }
class OpenAIProvider(LLMProvider): class OpenAIProvider(LLMProvider):
name = "OpenAI" name = "OpenAI"
models = ["gpt-3.5-turbo", "gpt-4o-mini", "gpt-4o", "o1-mini", "o1-preview"] models = ["gpt-3.5-turbo", "gpt-4o-mini", "gpt-4o", "o1-mini", "o1-preview"]
default_model = "gpt-4o-mini" default_model = "gpt-4o-mini"
class AnthropicProvider(LLMProvider): class AnthropicProvider(LLMProvider):
name = "Anthropic" name = "Anthropic"
models = [ models = [
@ -49,27 +51,31 @@ class AnthropicProvider(LLMProvider):
"claude-2.1", "claude-2.1",
"claude-2", "claude-2",
"claude-instant-1.2", "claude-instant-1.2",
"claude-instant-1" "claude-instant-1",
] ]
default_model = "claude-3-sonnet-20240229" default_model = "claude-3-sonnet-20240229"
class GeminiProvider(LLMProvider): class GeminiProvider(LLMProvider):
name = "Gemini" name = "Gemini"
models = ["gemini-pro", "gemini-1.5-pro-latest", "gemini-pro-vision"] models = ["gemini-pro", "gemini-1.5-pro-latest", "gemini-pro-vision"]
default_model = "gemini-pro" default_model = "gemini-pro"
SUPPORTED_PROVIDERS = { SUPPORTED_PROVIDERS = {
"openai": OpenAIProvider, "openai": OpenAIProvider,
"anthropic": AnthropicProvider, "anthropic": AnthropicProvider,
"gemini": GeminiProvider, "gemini": GeminiProvider,
} }
def get_llm_config() -> Tuple[str | None, str | None, str | None]: def get_llm_config() -> Tuple[str | None, str | None, str | None]:
""" """
Helper to get LLM configuration values, returns: Helper to get LLM configuration values, returns:
- api_key, model, provider - api_key, model, provider
""" """
api_key, provider_key, model = get_configuration_value([ api_key, provider_key, model = get_configuration_value(
[
{ {
"key": "LLM_API_KEY", "key": "LLM_API_KEY",
"default": os.environ.get("LLM_API_KEY", None), "default": os.environ.get("LLM_API_KEY", None),
@ -82,7 +88,8 @@ def get_llm_config() -> Tuple[str | None, str | None, str | None]:
"key": "LLM_MODEL", "key": "LLM_MODEL",
"default": os.environ.get("LLM_MODEL", None), "default": os.environ.get("LLM_MODEL", None),
}, },
]) ]
)
provider = SUPPORTED_PROVIDERS.get(provider_key.lower()) provider = SUPPORTED_PROVIDERS.get(provider_key.lower())
if not provider: if not provider:
@ -99,16 +106,20 @@ def get_llm_config() -> Tuple[str | None, str | None, str | None]:
# Validate model is supported by provider # Validate model is supported by provider
if model not in provider.models: if model not in provider.models:
log_exception(ValueError( log_exception(
ValueError(
f"Model {model} not supported by {provider.name}. " f"Model {model} not supported by {provider.name}. "
f"Supported models: {', '.join(provider.models)}" f"Supported models: {', '.join(provider.models)}"
)) )
)
return None, None, None return None, None, None
return api_key, model, provider_key return api_key, model, provider_key
def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> Tuple[str | None, str | None]: def get_llm_response(
task, prompt, api_key: str, model: str, provider: str
) -> Tuple[str | None, str | None]:
"""Helper to get LLM completion response""" """Helper to get LLM completion response"""
final_text = task + "\n" + prompt final_text = task + "\n" + prompt
try: try:
@ -118,10 +129,7 @@ def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> T
client = OpenAI(api_key=api_key) client = OpenAI(api_key=api_key)
chat_completion = client.chat.completions.create( chat_completion = client.chat.completions.create(
model=model, model=model, messages=[{"role": "user", "content": final_text}]
messages=[
{"role": "user", "content": final_text}
]
) )
text = chat_completion.choices[0].message.content text = chat_completion.choices[0].message.content
return text, None return text, None
@ -135,6 +143,7 @@ def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> T
else: else:
return None, f"Error occurred while generating response from {provider}" return None, f"Error occurred while generating response from {provider}"
class GPTIntegrationEndpoint(BaseAPIView): class GPTIntegrationEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
@ -152,7 +161,9 @@ class GPTIntegrationEndpoint(BaseAPIView):
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
) )
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider) text, error = get_llm_response(
task, request.data.get("prompt", False), api_key, model, provider
)
if not text and error: if not text and error:
return Response( return Response(
{"error": "An internal error has occurred."}, {"error": "An internal error has occurred."},
@ -190,7 +201,9 @@ class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
) )
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider) text, error = get_llm_response(
task, request.data.get("prompt", False), api_key, model, provider
)
if not text and error: if not text and error:
return Response( return Response(
{"error": "An internal error has occurred."}, {"error": "An internal error has occurred."},

View file

@ -38,6 +38,7 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina
from plane.app.permissions import allow_permission, ROLE from plane.app.permissions import allow_permission, ROLE
from plane.utils.error_codes import ERROR_CODES from plane.utils.error_codes import ERROR_CODES
from plane.utils.host import base_host from plane.utils.host import base_host
# Module imports # Module imports
from .. import BaseViewSet, BaseAPIView from .. import BaseViewSet, BaseAPIView

View file

@ -23,6 +23,7 @@ from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.utils.host import base_host from plane.utils.host import base_host
class IssueAttachmentEndpoint(BaseAPIView): class IssueAttachmentEndpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer serializer_class = IssueAttachmentSerializer
model = FileAsset model = FileAsset

View file

@ -32,6 +32,7 @@ from plane.app.serializers import (
IssueDetailSerializer, IssueDetailSerializer,
IssueUserPropertySerializer, IssueUserPropertySerializer,
IssueSerializer, IssueSerializer,
IssueListDetailSerializer,
) )
from plane.bgtasks.issue_activities_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import ( from plane.db.models import (
@ -46,6 +47,9 @@ from plane.db.models import (
CycleIssue, CycleIssue,
UserRecentVisit, UserRecentVisit,
ModuleIssue, ModuleIssue,
IssueRelation,
IssueAssignee,
IssueLabel,
) )
from plane.utils.grouper import ( from plane.utils.grouper import (
issue_group_values, issue_group_values,
@ -944,10 +948,57 @@ class IssueDetailEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id): def get(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
# check for the project member role, if the role is 5 then check for the guest_view_all_features
# if it is true then show all the issues else show only the issues created by the user
permission_subquery = (
Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id, id=OuterRef("id")
)
.filter(
Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role__gt=ROLE.GUEST.value,
)
| Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role=ROLE.GUEST.value,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role=ROLE.GUEST.value,
project__guest_view_all_features=False,
created_by=self.request.user,
)
)
.values("id")
)
# Main issue query
issue = ( issue = (
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id) Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
.select_related("workspace", "project", "state", "parent") .filter(Exists(permission_subquery))
.prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related(
Prefetch(
"issue_assignee",
queryset=IssueAssignee.objects.all(),
)
)
.prefetch_related(
Prefetch(
"label_issue",
queryset=IssueLabel.objects.all(),
)
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.all(),
)
)
.annotate( .annotate(
cycle_id=Subquery( cycle_id=Subquery(
CycleIssue.objects.filter( CycleIssue.objects.filter(
@ -955,43 +1006,6 @@ class IssueDetailEndpoint(BaseAPIView):
).values("cycle_id")[:1] ).values("cycle_id")[:1]
) )
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.annotate( .annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id")) link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by() .order_by()
@ -1014,6 +1028,24 @@ class IssueDetailEndpoint(BaseAPIView):
.values("count") .values("count")
) )
) )
# Add additional prefetch based on expand parameter
if self.expand:
if "issue_relation" in self.expand:
issue = issue.prefetch_related(
Prefetch(
"issue_relation",
queryset=IssueRelation.objects.select_related("related_issue"),
)
)
if "issue_related" in self.expand:
issue = issue.prefetch_related(
Prefetch(
"issue_related",
queryset=IssueRelation.objects.select_related("issue"),
)
)
issue = issue.filter(**filters) issue = issue.filter(**filters)
order_by_param = request.GET.get("order_by", "-created_at") order_by_param = request.GET.get("order_by", "-created_at")
# Issue queryset # Issue queryset
@ -1024,7 +1056,7 @@ class IssueDetailEndpoint(BaseAPIView):
request=request, request=request,
order_by=order_by_param, order_by=order_by_param,
queryset=(issue), queryset=(issue),
on_results=lambda issue: IssueSerializer( on_results=lambda issue: IssueListDetailSerializer(
issue, many=True, fields=self.fields, expand=self.expand issue, many=True, fields=self.fields, expand=self.expand
).data, ).data,
) )

View file

@ -19,6 +19,7 @@ from plane.db.models import IssueComment, ProjectMember, CommentReaction, Projec
from plane.bgtasks.issue_activities_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.host import base_host from plane.utils.host import base_host
class IssueCommentViewSet(BaseViewSet): class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer serializer_class = IssueCommentSerializer
model = IssueComment model = IssueComment

View file

@ -15,8 +15,10 @@ from plane.app.serializers import IssueLinkSerializer
from plane.app.permissions import ProjectEntityPermission from plane.app.permissions import ProjectEntityPermission
from plane.db.models import IssueLink from plane.db.models import IssueLink
from plane.bgtasks.issue_activities_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
from plane.utils.host import base_host from plane.utils.host import base_host
class IssueLinkViewSet(BaseViewSet): class IssueLinkViewSet(BaseViewSet):
permission_classes = [ProjectEntityPermission] permission_classes = [ProjectEntityPermission]
@ -43,6 +45,9 @@ class IssueLinkViewSet(BaseViewSet):
serializer = IssueLinkSerializer(data=request.data) serializer = IssueLinkSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id) serializer.save(project_id=project_id, issue_id=issue_id)
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay( issue_activity.delay(
type="link.activity.created", type="link.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
@ -54,6 +59,10 @@ class IssueLinkViewSet(BaseViewSet):
notification=True, notification=True,
origin=base_host(request=request, is_app=True), origin=base_host(request=request, is_app=True),
) )
issue_link = self.get_queryset().get(id=serializer.data.get("id"))
serializer = IssueLinkSerializer(issue_link)
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -65,9 +74,14 @@ class IssueLinkViewSet(BaseViewSet):
current_instance = json.dumps( current_instance = json.dumps(
IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder
) )
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay( issue_activity.delay(
type="link.activity.updated", type="link.activity.updated",
requested_data=requested_data, requested_data=requested_data,
@ -79,6 +93,9 @@ class IssueLinkViewSet(BaseViewSet):
notification=True, notification=True,
origin=base_host(request=request, is_app=True), origin=base_host(request=request, is_app=True),
) )
issue_link = self.get_queryset().get(id=serializer.data.get("id"))
serializer = IssueLinkSerializer(issue_link)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View file

@ -17,6 +17,7 @@ from plane.db.models import IssueReaction
from plane.bgtasks.issue_activities_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.host import base_host from plane.utils.host import base_host
class IssueReactionViewSet(BaseViewSet): class IssueReactionViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer serializer_class = IssueReactionSerializer
model = IssueReaction model = IssueReaction

View file

@ -29,6 +29,7 @@ from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.issue_relation_mapper import get_actual_relation from plane.utils.issue_relation_mapper import get_actual_relation
from plane.utils.host import base_host from plane.utils.host import base_host
class IssueRelationViewSet(BaseViewSet): class IssueRelationViewSet(BaseViewSet):
serializer_class = IssueRelationSerializer serializer_class = IssueRelationSerializer
model = IssueRelation model = IssueRelation

View file

@ -23,6 +23,8 @@ from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.timezone_converter import user_timezone_converter from plane.utils.timezone_converter import user_timezone_converter
from collections import defaultdict from collections import defaultdict
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.utils.order_queryset import order_issue_queryset
class SubIssuesEndpoint(BaseAPIView): class SubIssuesEndpoint(BaseAPIView):
permission_classes = [ProjectEntityPermission] permission_classes = [ProjectEntityPermission]
@ -102,6 +104,15 @@ class SubIssuesEndpoint(BaseAPIView):
.order_by("-created_at") .order_by("-created_at")
) )
# Ordering
order_by_param = request.GET.get("order_by", "-created_at")
group_by = request.GET.get("group_by", False)
if order_by_param:
sub_issues, order_by_param = order_issue_queryset(
sub_issues, order_by_param
)
# create's a dict with state group name with their respective issue id's # create's a dict with state group name with their respective issue id's
result = defaultdict(list) result = defaultdict(list)
for sub_issue in sub_issues: for sub_issue in sub_issues:
@ -138,6 +149,26 @@ class SubIssuesEndpoint(BaseAPIView):
sub_issues = user_timezone_converter( sub_issues = user_timezone_converter(
sub_issues, datetime_fields, request.user.user_timezone sub_issues, datetime_fields, request.user.user_timezone
) )
# Grouping
if group_by:
result_dict = defaultdict(list)
for issue in sub_issues:
if group_by == "assignees__ids":
if issue["assignee_ids"]:
assignee_ids = issue["assignee_ids"]
for assignee_id in assignee_ids:
result_dict[str(assignee_id)].append(issue)
elif issue["assignee_ids"] == []:
result_dict["None"].append(issue)
elif group_by:
result_dict[str(issue[group_by])].append(issue)
return Response(
{"sub_issues": result_dict, "state_distribution": result},
status=status.HTTP_200_OK,
)
return Response( return Response(
{"sub_issues": sub_issues, "state_distribution": result}, {"sub_issues": sub_issues, "state_distribution": result},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,

View file

@ -63,6 +63,7 @@ from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.recent_visited_task import recent_visited_task from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.host import base_host from plane.utils.host import base_host
class ModuleViewSet(BaseViewSet): class ModuleViewSet(BaseViewSet):
model = Module model = Module
webhook_event = "module" webhook_event = "module"
@ -710,23 +711,31 @@ class ModuleViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
module = self.get_queryset().filter(pk=pk) module_queryset = self.get_queryset().filter(pk=pk)
if module.first().archived_at: current_module = module_queryset.first()
if not current_module:
return Response(
{"error": "Module not found"},
status=status.HTTP_404_NOT_FOUND,
)
if current_module.archived_at:
return Response( return Response(
{"error": "Archived module cannot be updated"}, {"error": "Archived module cannot be updated"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
current_instance = json.dumps( current_instance = json.dumps(
ModuleSerializer(module.first()).data, cls=DjangoJSONEncoder ModuleSerializer(current_module).data, cls=DjangoJSONEncoder
) )
serializer = ModuleWriteSerializer( serializer = ModuleWriteSerializer(
module.first(), data=request.data, partial=True current_module, data=request.data, partial=True
) )
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
module = module.values( module = module_queryset.values(
# Required fields # Required fields
"id", "id",
"workspace_id", "workspace_id",

View file

@ -36,6 +36,7 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina
from .. import BaseViewSet from .. import BaseViewSet
from plane.utils.host import base_host from plane.utils.host import base_host
class ModuleIssueViewSet(BaseViewSet): class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer serializer_class = ModuleIssueSerializer
model = ModuleIssue model = ModuleIssue
@ -280,7 +281,11 @@ class ModuleIssueViewSet(BaseViewSet):
issue_id=str(issue_id), issue_id=str(issue_id),
project_id=str(project_id), project_id=str(project_id),
current_instance=json.dumps( current_instance=json.dumps(
{"module_name": module_issue.first().module.name if (module_issue.first() and module_issue.first().module) else None} {
"module_name": module_issue.first().module.name
if (module_issue.first() and module_issue.first().module)
else None
}
), ),
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
notification=True, notification=True,

View file

@ -42,6 +42,7 @@ from plane.bgtasks.page_version_task import page_version
from plane.bgtasks.recent_visited_task import recent_visited_task from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.copy_s3_object import copy_s3_objects from plane.bgtasks.copy_s3_object import copy_s3_objects
def unarchive_archive_page_and_descendants(page_id, archived_at): def unarchive_archive_page_and_descendants(page_id, archived_at):
# Your SQL query # Your SQL query
sql = """ sql = """
@ -572,6 +573,12 @@ class PageDuplicateEndpoint(BaseAPIView):
pk=page_id, workspace__slug=slug, projects__id=project_id pk=page_id, workspace__slug=slug, projects__id=project_id
).first() ).first()
# check for permission
if page.access == Page.PRIVATE_ACCESS and page.owned_by_id != request.user.id:
return Response(
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
)
# get all the project ids where page is present # get all the project ids where page is present
project_ids = ProjectPage.objects.filter(page_id=page_id).values_list( project_ids = ProjectPage.objects.filter(page_id=page_id).values_list(
"project_id", flat=True "project_id", flat=True

View file

@ -275,14 +275,14 @@ class ProjectViewSet(BaseViewSet):
states = [ states = [
{ {
"name": "Backlog", "name": "Backlog",
"color": "#A3A3A3", "color": "#60646C",
"sequence": 15000, "sequence": 15000,
"group": "backlog", "group": "backlog",
"default": True, "default": True,
}, },
{ {
"name": "Todo", "name": "Todo",
"color": "#3A3A3A", "color": "#60646C",
"sequence": 25000, "sequence": 25000,
"group": "unstarted", "group": "unstarted",
}, },
@ -294,13 +294,13 @@ class ProjectViewSet(BaseViewSet):
}, },
{ {
"name": "Done", "name": "Done",
"color": "#16A34A", "color": "#46A758",
"sequence": 45000, "sequence": 45000,
"group": "completed", "group": "completed",
}, },
{ {
"name": "Cancelled", "name": "Cancelled",
"color": "#EF4444", "color": "#9AA4BC",
"sequence": 55000, "sequence": 55000,
"group": "cancelled", "group": "cancelled",
}, },
@ -341,7 +341,10 @@ class ProjectViewSet(BaseViewSet):
except IntegrityError as e: except IntegrityError as e:
if "already exists" in str(e): if "already exists" in str(e):
return Response( return Response(
{"name": "The project name is already taken"}, {
"name": "The project name is already taken",
"code": "PROJECT_NAME_ALREADY_EXIST",
},
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,
) )
except Workspace.DoesNotExist: except Workspace.DoesNotExist:
@ -350,7 +353,10 @@ class ProjectViewSet(BaseViewSet):
) )
except serializers.ValidationError: except serializers.ValidationError:
return Response( return Response(
{"identifier": "The project identifier is already taken"}, {
"identifier": "The project identifier is already taken",
"code": "PROJECT_IDENTIFIER_ALREADY_EXIST",
},
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,
) )
@ -445,7 +451,7 @@ class ProjectViewSet(BaseViewSet):
is_active=True, is_active=True,
).exists() ).exists()
): ):
project = Project.objects.get(pk=pk) project = Project.objects.get(pk=pk, workspace__slug=slug)
project.delete() project.delete()
webhook_activity.delay( webhook_activity.delay(
event="project", event="project",

View file

@ -29,6 +29,7 @@ from plane.db.models import (
from plane.db.models.project import ProjectNetwork from plane.db.models.project import ProjectNetwork
from plane.utils.host import base_host from plane.utils.host import base_host
class ProjectInvitationsViewset(BaseViewSet): class ProjectInvitationsViewset(BaseViewSet):
serializer_class = ProjectMemberInviteSerializer serializer_class = ProjectMemberInviteSerializer
model = ProjectMemberInvite model = ProjectMemberInvite

View file

@ -168,6 +168,8 @@ class ProjectMemberViewSet(BaseViewSet):
workspace__slug=slug, workspace__slug=slug,
member__is_bot=False, member__is_bot=False,
is_active=True, is_active=True,
member__member_workspace__workspace__slug=slug,
member__member_workspace__is_active=True,
).select_related("project", "member", "workspace") ).select_related("project", "member", "workspace")
serializer = ProjectMemberRoleSerializer( serializer = ProjectMemberRoleSerializer(
@ -313,7 +315,11 @@ class UserProjectRolesEndpoint(BaseAPIView):
def get(self, request, slug): def get(self, request, slug):
project_members = ProjectMember.objects.filter( project_members = ProjectMember.objects.filter(
workspace__slug=slug, member_id=request.user.id, is_active=True workspace__slug=slug,
member_id=request.user.id,
is_active=True,
member__member_workspace__workspace__slug=slug,
member__member_workspace__is_active=True,
).values("project_id", "role") ).values("project_id", "role")
project_members = { project_members = {

View file

@ -1,5 +1,5 @@
# Django imports # Django imports
from django.db.models import Q from django.db.models import Q, QuerySet
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
@ -12,6 +12,95 @@ from plane.utils.issue_search import search_issues
class IssueSearchEndpoint(BaseAPIView): class IssueSearchEndpoint(BaseAPIView):
def filter_issues_by_project(self, project_id: int, issues: QuerySet) -> QuerySet:
"""
Filter issues by project
"""
issues = issues.filter(project_id=project_id)
return issues
def search_issues_by_query(self, query: str, issues: QuerySet) -> QuerySet:
"""
Search issues by query
"""
issues = search_issues(query, issues)
return issues
def search_issues_and_excluding_parent(
self, issues: QuerySet, issue_id: str
) -> QuerySet:
"""
Search issues and epics by query excluding the parent
"""
issue = Issue.issue_objects.filter(pk=issue_id).first()
if issue:
issues = issues.filter(
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
)
return issues
def filter_issues_excluding_related_issues(
self, issue_id: str, issues: QuerySet
) -> QuerySet:
"""
Filter issues excluding related issues
"""
issue = Issue.issue_objects.filter(pk=issue_id).first()
related_issue_ids = (
IssueRelation.objects.filter(Q(related_issue=issue) | Q(issue=issue))
.values_list("issue_id", "related_issue_id")
.distinct()
)
related_issue_ids = [item for sublist in related_issue_ids for item in sublist]
if issue:
issues = issues.filter(~Q(pk=issue_id), ~Q(pk__in=related_issue_ids))
return issues
def filter_root_issues_only(self, issue_id: str, issues: QuerySet) -> QuerySet:
"""
Filter root issues only
"""
issue = Issue.issue_objects.filter(pk=issue_id).first()
if issue:
issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
if issue.parent:
issues = issues.filter(~Q(pk=issue.parent_id))
return issues
def exclude_issues_in_cycles(self, issues: QuerySet) -> QuerySet:
"""
Exclude issues in cycles
"""
issues = issues.exclude(
Q(issue_cycle__isnull=False) & Q(issue_cycle__deleted_at__isnull=True)
)
return issues
def exclude_issues_in_module(self, issues: QuerySet, module: str) -> QuerySet:
"""
Exclude issues in a module
"""
issues = issues.exclude(
Q(issue_module__module=module) & Q(issue_module__deleted_at__isnull=True)
)
return issues
def filter_issues_without_target_date(self, issues: QuerySet) -> QuerySet:
"""
Filter issues without a target date
"""
issues = issues.filter(target_date__isnull=True)
return issues
def get(self, request, slug, project_id): def get(self, request, slug, project_id):
query = request.query_params.get("search", False) query = request.query_params.get("search", False)
workspace_search = request.query_params.get("workspace_search", "false") workspace_search = request.query_params.get("workspace_search", "false")
@ -21,7 +110,6 @@ class IssueSearchEndpoint(BaseAPIView):
module = request.query_params.get("module", False) module = request.query_params.get("module", False)
sub_issue = request.query_params.get("sub_issue", "false") sub_issue = request.query_params.get("sub_issue", "false")
target_date = request.query_params.get("target_date", True) target_date = request.query_params.get("target_date", True)
issue_id = request.query_params.get("issue_id", False) issue_id = request.query_params.get("issue_id", False)
issues = Issue.issue_objects.filter( issues = Issue.issue_objects.filter(
@ -32,52 +120,28 @@ class IssueSearchEndpoint(BaseAPIView):
) )
if workspace_search == "false": if workspace_search == "false":
issues = issues.filter(project_id=project_id) issues = self.filter_issues_by_project(project_id, issues)
if query: if query:
issues = search_issues(query, issues) issues = self.search_issues_by_query(query, issues)
if parent == "true" and issue_id: if parent == "true" and issue_id:
issue = Issue.issue_objects.filter(pk=issue_id).first() issues = self.search_issues_and_excluding_parent(issues, issue_id)
if issue:
issues = issues.filter(
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
)
if issue_relation == "true" and issue_id: if issue_relation == "true" and issue_id:
issue = Issue.issue_objects.filter(pk=issue_id).first() issues = self.filter_issues_excluding_related_issues(issue_id, issues)
related_issue_ids = IssueRelation.objects.filter(
Q(related_issue=issue) | Q(issue=issue)
).values_list(
"issue_id", "related_issue_id"
).distinct()
related_issue_ids = [item for sublist in related_issue_ids for item in sublist]
if issue:
issues = issues.filter(
~Q(pk=issue_id),
~Q(pk__in=related_issue_ids),
)
if sub_issue == "true" and issue_id: if sub_issue == "true" and issue_id:
issue = Issue.issue_objects.filter(pk=issue_id).first() issues = self.filter_root_issues_only(issue_id, issues)
if issue:
issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
if issue.parent:
issues = issues.filter(~Q(pk=issue.parent_id))
if cycle == "true": if cycle == "true":
issues = issues.exclude( issues = self.exclude_issues_in_cycles(issues)
Q(issue_cycle__isnull=False) & Q(issue_cycle__deleted_at__isnull=True)
)
if module: if module:
issues = issues.exclude( issues = self.exclude_issues_in_module(issues, module)
Q(issue_module__module=module)
& Q(issue_module__deleted_at__isnull=True)
)
if target_date == "none": if target_date == "none":
issues = issues.filter(target_date__isnull=True) issues = self.filter_issues_without_target_date(issues)
if ProjectMember.objects.filter( if ProjectMember.objects.filter(
project_id=project_id, member=self.request.user, is_active=True, role=5 project_id=project_id, member=self.request.user, is_active=True, role=5

View file

@ -1,5 +1,6 @@
# Python imports # Python imports
from itertools import groupby from itertools import groupby
from collections import defaultdict
# Django imports # Django imports
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
@ -74,7 +75,19 @@ class StateViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
states = StateSerializer(self.get_queryset(), many=True).data states = StateSerializer(self.get_queryset(), many=True).data
grouped_states = defaultdict(list)
for state in states:
grouped_states[state["group"]].append(state)
for group, group_states in grouped_states.items():
count = len(group_states)
for index, state in enumerate(group_states, start=1):
state["order"] = index / count
grouped = request.GET.get("grouped", False) grouped = request.GET.get("grouped", False)
if grouped == "true": if grouped == "true":
state_dict = {} state_dict = {}
for key, value in groupby( for key, value in groupby(
@ -83,6 +96,7 @@ class StateViewSet(BaseViewSet):
): ):
state_dict[str(key)] = list(value) state_dict[str(key)] = list(value)
return Response(state_dict, status=status.HTTP_200_OK) return Response(state_dict, status=status.HTTP_200_OK)
return Response(states, status=status.HTTP_200_OK) return Response(states, status=status.HTTP_200_OK)
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False) @invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)

View file

@ -24,125 +24,152 @@ class TimezoneEndpoint(APIView):
@method_decorator(cache_page(60 * 60 * 2)) @method_decorator(cache_page(60 * 60 * 2))
def get(self, request): def get(self, request):
timezone_locations = [ timezone_locations = [
('Midway Island', 'Pacific/Midway'), # UTC-11:00 ("Midway Island", "Pacific/Midway"), # UTC-11:00
('American Samoa', 'Pacific/Pago_Pago'), # UTC-11:00 ("American Samoa", "Pacific/Pago_Pago"), # UTC-11:00
('Hawaii', 'Pacific/Honolulu'), # UTC-10:00 ("Hawaii", "Pacific/Honolulu"), # UTC-10:00
('Aleutian Islands', 'America/Adak'), # UTC-10:00 (DST: UTC-09:00) ("Aleutian Islands", "America/Adak"), # UTC-10:00 (DST: UTC-09:00)
('Marquesas Islands', 'Pacific/Marquesas'), # UTC-09:30 ("Marquesas Islands", "Pacific/Marquesas"), # UTC-09:30
('Alaska', 'America/Anchorage'), # UTC-09:00 (DST: UTC-08:00) ("Alaska", "America/Anchorage"), # UTC-09:00 (DST: UTC-08:00)
('Gambier Islands', 'Pacific/Gambier'), # UTC-09:00 ("Gambier Islands", "Pacific/Gambier"), # UTC-09:00
('Pacific Time (US and Canada)', 'America/Los_Angeles'), # UTC-08:00 (DST: UTC-07:00) (
('Baja California', 'America/Tijuana'), # UTC-08:00 (DST: UTC-07:00) "Pacific Time (US and Canada)",
('Mountain Time (US and Canada)', 'America/Denver'), # UTC-07:00 (DST: UTC-06:00) "America/Los_Angeles",
('Arizona', 'America/Phoenix'), # UTC-07:00 ), # UTC-08:00 (DST: UTC-07:00)
('Chihuahua, Mazatlan', 'America/Chihuahua'), # UTC-07:00 (DST: UTC-06:00) ("Baja California", "America/Tijuana"), # UTC-08:00 (DST: UTC-07:00)
('Central Time (US and Canada)', 'America/Chicago'), # UTC-06:00 (DST: UTC-05:00) (
('Saskatchewan', 'America/Regina'), # UTC-06:00 "Mountain Time (US and Canada)",
('Guadalajara, Mexico City, Monterrey', 'America/Mexico_City'), # UTC-06:00 (DST: UTC-05:00) "America/Denver",
('Tegucigalpa, Honduras', 'America/Tegucigalpa'), # UTC-06:00 ), # UTC-07:00 (DST: UTC-06:00)
('Costa Rica', 'America/Costa_Rica'), # UTC-06:00 ("Arizona", "America/Phoenix"), # UTC-07:00
('Eastern Time (US and Canada)', 'America/New_York'), # UTC-05:00 (DST: UTC-04:00) ("Chihuahua, Mazatlan", "America/Chihuahua"), # UTC-07:00 (DST: UTC-06:00)
('Lima', 'America/Lima'), # UTC-05:00 (
('Bogota', 'America/Bogota'), # UTC-05:00 "Central Time (US and Canada)",
('Quito', 'America/Guayaquil'), # UTC-05:00 "America/Chicago",
('Chetumal', 'America/Cancun'), # UTC-05:00 (DST: UTC-04:00) ), # UTC-06:00 (DST: UTC-05:00)
('Caracas (Old Venezuela Time)', 'America/Caracas'), # UTC-04:30 ("Saskatchewan", "America/Regina"), # UTC-06:00
('Atlantic Time (Canada)', 'America/Halifax'), # UTC-04:00 (DST: UTC-03:00) (
('Caracas', 'America/Caracas'), # UTC-04:00 "Guadalajara, Mexico City, Monterrey",
('Santiago', 'America/Santiago'), # UTC-04:00 (DST: UTC-03:00) "America/Mexico_City",
('La Paz', 'America/La_Paz'), # UTC-04:00 ), # UTC-06:00 (DST: UTC-05:00)
('Manaus', 'America/Manaus'), # UTC-04:00 ("Tegucigalpa, Honduras", "America/Tegucigalpa"), # UTC-06:00
('Georgetown', 'America/Guyana'), # UTC-04:00 ("Costa Rica", "America/Costa_Rica"), # UTC-06:00
('Bermuda', 'Atlantic/Bermuda'), # UTC-04:00 (DST: UTC-03:00) (
('Newfoundland Time (Canada)', 'America/St_Johns'), # UTC-03:30 (DST: UTC-02:30) "Eastern Time (US and Canada)",
('Buenos Aires', 'America/Argentina/Buenos_Aires'), # UTC-03:00 "America/New_York",
('Brasilia', 'America/Sao_Paulo'), # UTC-03:00 ), # UTC-05:00 (DST: UTC-04:00)
('Greenland', 'America/Godthab'), # UTC-03:00 (DST: UTC-02:00) ("Lima", "America/Lima"), # UTC-05:00
('Montevideo', 'America/Montevideo'), # UTC-03:00 ("Bogota", "America/Bogota"), # UTC-05:00
('Falkland Islands', 'Atlantic/Stanley'), # UTC-03:00 ("Quito", "America/Guayaquil"), # UTC-05:00
('South Georgia and the South Sandwich Islands', 'Atlantic/South_Georgia'), # UTC-02:00 ("Chetumal", "America/Cancun"), # UTC-05:00 (DST: UTC-04:00)
('Azores', 'Atlantic/Azores'), # UTC-01:00 (DST: UTC+00:00) ("Caracas (Old Venezuela Time)", "America/Caracas"), # UTC-04:30
('Cape Verde Islands', 'Atlantic/Cape_Verde'), # UTC-01:00 ("Atlantic Time (Canada)", "America/Halifax"), # UTC-04:00 (DST: UTC-03:00)
('Dublin', 'Europe/Dublin'), # UTC+00:00 (DST: UTC+01:00) ("Caracas", "America/Caracas"), # UTC-04:00
('Reykjavik', 'Atlantic/Reykjavik'), # UTC+00:00 ("Santiago", "America/Santiago"), # UTC-04:00 (DST: UTC-03:00)
('Lisbon', 'Europe/Lisbon'), # UTC+00:00 (DST: UTC+01:00) ("La Paz", "America/La_Paz"), # UTC-04:00
('Monrovia', 'Africa/Monrovia'), # UTC+00:00 ("Manaus", "America/Manaus"), # UTC-04:00
('Casablanca', 'Africa/Casablanca'), # UTC+00:00 (DST: UTC+01:00) ("Georgetown", "America/Guyana"), # UTC-04:00
('Central European Time (Berlin, Rome, Paris)', 'Europe/Paris'), # UTC+01:00 (DST: UTC+02:00) ("Bermuda", "Atlantic/Bermuda"), # UTC-04:00 (DST: UTC-03:00)
('West Central Africa', 'Africa/Lagos'), # UTC+01:00 (
('Algiers', 'Africa/Algiers'), # UTC+01:00 "Newfoundland Time (Canada)",
('Lagos', 'Africa/Lagos'), # UTC+01:00 "America/St_Johns",
('Tunis', 'Africa/Tunis'), # UTC+01:00 ), # UTC-03:30 (DST: UTC-02:30)
('Eastern European Time (Cairo, Helsinki, Kyiv)', 'Europe/Kiev'), # UTC+02:00 (DST: UTC+03:00) ("Buenos Aires", "America/Argentina/Buenos_Aires"), # UTC-03:00
('Athens', 'Europe/Athens'), # UTC+02:00 (DST: UTC+03:00) ("Brasilia", "America/Sao_Paulo"), # UTC-03:00
('Jerusalem', 'Asia/Jerusalem'), # UTC+02:00 (DST: UTC+03:00) ("Greenland", "America/Godthab"), # UTC-03:00 (DST: UTC-02:00)
('Johannesburg', 'Africa/Johannesburg'), # UTC+02:00 ("Montevideo", "America/Montevideo"), # UTC-03:00
('Harare, Pretoria', 'Africa/Harare'), # UTC+02:00 ("Falkland Islands", "Atlantic/Stanley"), # UTC-03:00
('Moscow Time', 'Europe/Moscow'), # UTC+03:00 (
('Baghdad', 'Asia/Baghdad'), # UTC+03:00 "South Georgia and the South Sandwich Islands",
('Nairobi', 'Africa/Nairobi'), # UTC+03:00 "Atlantic/South_Georgia",
('Kuwait, Riyadh', 'Asia/Riyadh'), # UTC+03:00 ), # UTC-02:00
('Tehran', 'Asia/Tehran'), # UTC+03:30 (DST: UTC+04:30) ("Azores", "Atlantic/Azores"), # UTC-01:00 (DST: UTC+00:00)
('Abu Dhabi', 'Asia/Dubai'), # UTC+04:00 ("Cape Verde Islands", "Atlantic/Cape_Verde"), # UTC-01:00
('Baku', 'Asia/Baku'), # UTC+04:00 (DST: UTC+05:00) ("Dublin", "Europe/Dublin"), # UTC+00:00 (DST: UTC+01:00)
('Yerevan', 'Asia/Yerevan'), # UTC+04:00 (DST: UTC+05:00) ("Reykjavik", "Atlantic/Reykjavik"), # UTC+00:00
('Astrakhan', 'Europe/Astrakhan'), # UTC+04:00 ("Lisbon", "Europe/Lisbon"), # UTC+00:00 (DST: UTC+01:00)
('Tbilisi', 'Asia/Tbilisi'), # UTC+04:00 ("Monrovia", "Africa/Monrovia"), # UTC+00:00
('Mauritius', 'Indian/Mauritius'), # UTC+04:00 ("Casablanca", "Africa/Casablanca"), # UTC+00:00 (DST: UTC+01:00)
('Islamabad', 'Asia/Karachi'), # UTC+05:00 (
('Karachi', 'Asia/Karachi'), # UTC+05:00 "Central European Time (Berlin, Rome, Paris)",
('Tashkent', 'Asia/Tashkent'), # UTC+05:00 "Europe/Paris",
('Yekaterinburg', 'Asia/Yekaterinburg'), # UTC+05:00 ), # UTC+01:00 (DST: UTC+02:00)
('Maldives', 'Indian/Maldives'), # UTC+05:00 ("West Central Africa", "Africa/Lagos"), # UTC+01:00
('Chagos', 'Indian/Chagos'), # UTC+05:00 ("Algiers", "Africa/Algiers"), # UTC+01:00
('Chennai', 'Asia/Kolkata'), # UTC+05:30 ("Lagos", "Africa/Lagos"), # UTC+01:00
('Kolkata', 'Asia/Kolkata'), # UTC+05:30 ("Tunis", "Africa/Tunis"), # UTC+01:00
('Mumbai', 'Asia/Kolkata'), # UTC+05:30 (
('New Delhi', 'Asia/Kolkata'), # UTC+05:30 "Eastern European Time (Cairo, Helsinki, Kyiv)",
('Sri Jayawardenepura', 'Asia/Colombo'), # UTC+05:30 "Europe/Kiev",
('Kathmandu', 'Asia/Kathmandu'), # UTC+05:45 ), # UTC+02:00 (DST: UTC+03:00)
('Dhaka', 'Asia/Dhaka'), # UTC+06:00 ("Athens", "Europe/Athens"), # UTC+02:00 (DST: UTC+03:00)
('Almaty', 'Asia/Almaty'), # UTC+06:00 ("Jerusalem", "Asia/Jerusalem"), # UTC+02:00 (DST: UTC+03:00)
('Bishkek', 'Asia/Bishkek'), # UTC+06:00 ("Johannesburg", "Africa/Johannesburg"), # UTC+02:00
('Thimphu', 'Asia/Thimphu'), # UTC+06:00 ("Harare, Pretoria", "Africa/Harare"), # UTC+02:00
('Yangon (Rangoon)', 'Asia/Yangon'), # UTC+06:30 ("Moscow Time", "Europe/Moscow"), # UTC+03:00
('Cocos Islands', 'Indian/Cocos'), # UTC+06:30 ("Baghdad", "Asia/Baghdad"), # UTC+03:00
('Bangkok', 'Asia/Bangkok'), # UTC+07:00 ("Nairobi", "Africa/Nairobi"), # UTC+03:00
('Hanoi', 'Asia/Ho_Chi_Minh'), # UTC+07:00 ("Kuwait, Riyadh", "Asia/Riyadh"), # UTC+03:00
('Jakarta', 'Asia/Jakarta'), # UTC+07:00 ("Tehran", "Asia/Tehran"), # UTC+03:30 (DST: UTC+04:30)
('Novosibirsk', 'Asia/Novosibirsk'), # UTC+07:00 ("Abu Dhabi", "Asia/Dubai"), # UTC+04:00
('Krasnoyarsk', 'Asia/Krasnoyarsk'), # UTC+07:00 ("Baku", "Asia/Baku"), # UTC+04:00 (DST: UTC+05:00)
('Beijing', 'Asia/Shanghai'), # UTC+08:00 ("Yerevan", "Asia/Yerevan"), # UTC+04:00 (DST: UTC+05:00)
('Singapore', 'Asia/Singapore'), # UTC+08:00 ("Astrakhan", "Europe/Astrakhan"), # UTC+04:00
('Perth', 'Australia/Perth'), # UTC+08:00 ("Tbilisi", "Asia/Tbilisi"), # UTC+04:00
('Hong Kong', 'Asia/Hong_Kong'), # UTC+08:00 ("Mauritius", "Indian/Mauritius"), # UTC+04:00
('Ulaanbaatar', 'Asia/Ulaanbaatar'), # UTC+08:00 ("Islamabad", "Asia/Karachi"), # UTC+05:00
('Palau', 'Pacific/Palau'), # UTC+08:00 ("Karachi", "Asia/Karachi"), # UTC+05:00
('Eucla', 'Australia/Eucla'), # UTC+08:45 ("Tashkent", "Asia/Tashkent"), # UTC+05:00
('Tokyo', 'Asia/Tokyo'), # UTC+09:00 ("Yekaterinburg", "Asia/Yekaterinburg"), # UTC+05:00
('Seoul', 'Asia/Seoul'), # UTC+09:00 ("Maldives", "Indian/Maldives"), # UTC+05:00
('Yakutsk', 'Asia/Yakutsk'), # UTC+09:00 ("Chagos", "Indian/Chagos"), # UTC+05:00
('Adelaide', 'Australia/Adelaide'), # UTC+09:30 (DST: UTC+10:30) ("Chennai", "Asia/Kolkata"), # UTC+05:30
('Darwin', 'Australia/Darwin'), # UTC+09:30 ("Kolkata", "Asia/Kolkata"), # UTC+05:30
('Sydney', 'Australia/Sydney'), # UTC+10:00 (DST: UTC+11:00) ("Mumbai", "Asia/Kolkata"), # UTC+05:30
('Brisbane', 'Australia/Brisbane'), # UTC+10:00 ("New Delhi", "Asia/Kolkata"), # UTC+05:30
('Guam', 'Pacific/Guam'), # UTC+10:00 ("Sri Jayawardenepura", "Asia/Colombo"), # UTC+05:30
('Vladivostok', 'Asia/Vladivostok'), # UTC+10:00 ("Kathmandu", "Asia/Kathmandu"), # UTC+05:45
('Tahiti', 'Pacific/Tahiti'), # UTC+10:00 ("Dhaka", "Asia/Dhaka"), # UTC+06:00
('Lord Howe Island', 'Australia/Lord_Howe'), # UTC+10:30 (DST: UTC+11:00) ("Almaty", "Asia/Almaty"), # UTC+06:00
('Solomon Islands', 'Pacific/Guadalcanal'), # UTC+11:00 ("Bishkek", "Asia/Bishkek"), # UTC+06:00
('Magadan', 'Asia/Magadan'), # UTC+11:00 ("Thimphu", "Asia/Thimphu"), # UTC+06:00
('Norfolk Island', 'Pacific/Norfolk'), # UTC+11:00 ("Yangon (Rangoon)", "Asia/Yangon"), # UTC+06:30
('Bougainville Island', 'Pacific/Bougainville'), # UTC+11:00 ("Cocos Islands", "Indian/Cocos"), # UTC+06:30
('Chokurdakh', 'Asia/Srednekolymsk'), # UTC+11:00 ("Bangkok", "Asia/Bangkok"), # UTC+07:00
('Auckland', 'Pacific/Auckland'), # UTC+12:00 (DST: UTC+13:00) ("Hanoi", "Asia/Ho_Chi_Minh"), # UTC+07:00
('Wellington', 'Pacific/Auckland'), # UTC+12:00 (DST: UTC+13:00) ("Jakarta", "Asia/Jakarta"), # UTC+07:00
('Fiji Islands', 'Pacific/Fiji'), # UTC+12:00 (DST: UTC+13:00) ("Novosibirsk", "Asia/Novosibirsk"), # UTC+07:00
('Anadyr', 'Asia/Anadyr'), # UTC+12:00 ("Krasnoyarsk", "Asia/Krasnoyarsk"), # UTC+07:00
('Chatham Islands', 'Pacific/Chatham'), # UTC+12:45 (DST: UTC+13:45) ("Beijing", "Asia/Shanghai"), # UTC+08:00
("Nuku'alofa", 'Pacific/Tongatapu'), # UTC+13:00 ("Singapore", "Asia/Singapore"), # UTC+08:00
('Samoa', 'Pacific/Apia'), # UTC+13:00 (DST: UTC+14:00) ("Perth", "Australia/Perth"), # UTC+08:00
('Kiritimati Island', 'Pacific/Kiritimati') # UTC+14:00 ("Hong Kong", "Asia/Hong_Kong"), # UTC+08:00
("Ulaanbaatar", "Asia/Ulaanbaatar"), # UTC+08:00
("Palau", "Pacific/Palau"), # UTC+08:00
("Eucla", "Australia/Eucla"), # UTC+08:45
("Tokyo", "Asia/Tokyo"), # UTC+09:00
("Seoul", "Asia/Seoul"), # UTC+09:00
("Yakutsk", "Asia/Yakutsk"), # UTC+09:00
("Adelaide", "Australia/Adelaide"), # UTC+09:30 (DST: UTC+10:30)
("Darwin", "Australia/Darwin"), # UTC+09:30
("Sydney", "Australia/Sydney"), # UTC+10:00 (DST: UTC+11:00)
("Brisbane", "Australia/Brisbane"), # UTC+10:00
("Guam", "Pacific/Guam"), # UTC+10:00
("Vladivostok", "Asia/Vladivostok"), # UTC+10:00
("Tahiti", "Pacific/Tahiti"), # UTC+10:00
("Lord Howe Island", "Australia/Lord_Howe"), # UTC+10:30 (DST: UTC+11:00)
("Solomon Islands", "Pacific/Guadalcanal"), # UTC+11:00
("Magadan", "Asia/Magadan"), # UTC+11:00
("Norfolk Island", "Pacific/Norfolk"), # UTC+11:00
("Bougainville Island", "Pacific/Bougainville"), # UTC+11:00
("Chokurdakh", "Asia/Srednekolymsk"), # UTC+11:00
("Auckland", "Pacific/Auckland"), # UTC+12:00 (DST: UTC+13:00)
("Wellington", "Pacific/Auckland"), # UTC+12:00 (DST: UTC+13:00)
("Fiji Islands", "Pacific/Fiji"), # UTC+12:00 (DST: UTC+13:00)
("Anadyr", "Asia/Anadyr"), # UTC+12:00
("Chatham Islands", "Pacific/Chatham"), # UTC+12:45 (DST: UTC+13:45)
("Nuku'alofa", "Pacific/Tongatapu"), # UTC+13:00
("Samoa", "Pacific/Apia"), # UTC+13:00 (DST: UTC+14:00)
("Kiritimati Island", "Pacific/Kiritimati"), # UTC+14:00
] ]
timezone_list = [] timezone_list = []
@ -150,7 +177,6 @@ class TimezoneEndpoint(APIView):
# Process timezone mapping # Process timezone mapping
for friendly_name, tz_identifier in timezone_locations: for friendly_name, tz_identifier in timezone_locations:
try: try:
tz = pytz.timezone(tz_identifier) tz = pytz.timezone(tz_identifier)
current_offset = now.astimezone(tz).strftime("%z") current_offset = now.astimezone(tz).strftime("%z")

View file

@ -1,8 +1,13 @@
# Django imports # Django imports
from django.contrib.postgres.aggregates import ArrayAgg from django.db.models import (
from django.contrib.postgres.fields import ArrayField Exists,
from django.db.models import Exists, F, Func, OuterRef, Q, UUIDField, Value, Subquery F,
from django.db.models.functions import Coalesce Func,
OuterRef,
Q,
Subquery,
Prefetch,
)
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.db import transaction from django.db import transaction
@ -13,7 +18,7 @@ from rest_framework.response import Response
# Module imports # Module imports
from plane.app.permissions import allow_permission, ROLE from plane.app.permissions import allow_permission, ROLE
from plane.app.serializers import IssueViewSerializer from plane.app.serializers import IssueViewSerializer, ViewIssueListSerializer
from plane.db.models import ( from plane.db.models import (
Issue, Issue,
FileAsset, FileAsset,
@ -25,15 +30,12 @@ from plane.db.models import (
Project, Project,
CycleIssue, CycleIssue,
UserRecentVisit, UserRecentVisit,
) IssueAssignee,
from plane.utils.grouper import ( IssueLabel,
issue_group_values, ModuleIssue,
issue_on_results,
issue_queryset_grouper,
) )
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from plane.bgtasks.recent_visited_task import recent_visited_task from plane.bgtasks.recent_visited_task import recent_visited_task
from .. import BaseViewSet from .. import BaseViewSet
from plane.db.models import UserFavorite from plane.db.models import UserFavorite
@ -143,6 +145,28 @@ class WorkspaceViewViewSet(BaseViewSet):
class WorkspaceViewIssuesViewSet(BaseViewSet): class WorkspaceViewIssuesViewSet(BaseViewSet):
def _get_project_permission_filters(self):
"""
Get common project permission filters for guest users and role-based access control.
Returns Q object for filtering issues based on user role and project settings.
"""
return Q(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role > 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
def get_queryset(self): def get_queryset(self):
return ( return (
Issue.issue_objects.annotate( Issue.issue_objects.annotate(
@ -152,12 +176,25 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
.values("count") .values("count")
) )
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter( .select_related("state")
project__project_projectmember__member=self.request.user, .prefetch_related(
project__project_projectmember__is_active=True, Prefetch(
"issue_assignee",
queryset=IssueAssignee.objects.all(),
)
)
.prefetch_related(
Prefetch(
"label_issue",
queryset=IssueLabel.objects.all(),
)
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.all(),
)
) )
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate( .annotate(
cycle_id=Subquery( cycle_id=Subquery(
CycleIssue.objects.filter( CycleIssue.objects.filter(
@ -186,43 +223,6 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
) )
@method_decorator(gzip_page) @method_decorator(gzip_page)
@ -233,124 +233,34 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at") order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = ( issue_queryset = self.get_queryset().filter(**filters)
self.get_queryset()
.filter(**filters) # Get common project permission filters
.annotate( permission_filters = self._get_project_permission_filters()
cycle_id=Subquery(
CycleIssue.objects.filter( # Base query for the counts
issue=OuterRef("id"), deleted_at__isnull=True total_issue_count = (
).values("cycle_id")[:1] Issue.issue_objects.filter(**filters)
) .filter(workspace__slug=slug)
) .filter(permission_filters)
.only("id")
) )
# check for the project member role, if the role is 5 then check for the guest_view_all_features if it is true then show all the issues else show only the issues created by the user # Apply project permission filters to the issue queryset
issue_queryset = issue_queryset.filter(permission_filters)
issue_queryset = issue_queryset.filter(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role < 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
# Issue queryset # Issue queryset
issue_queryset, order_by_param = order_issue_queryset( issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset, order_by_param=order_by_param issue_queryset=issue_queryset, order_by_param=order_by_param
) )
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by
)
if group_by:
# Check group and sub group value paginate
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# group and sub group pagination
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by, slug=slug, project_id=None, filters=filters
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=None,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_intake__status=1)
| Q(issue_intake__status=-1)
| Q(issue_intake__status=2)
| Q(issue_intake__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
# Group Paginate
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by, slug=slug, project_id=None, filters=filters
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_intake__status=1)
| Q(issue_intake__status=-1)
| Q(issue_intake__status=2)
| Q(issue_intake__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# List Paginate # List Paginate
return self.paginate( return self.paginate(
order_by=order_by_param, order_by=order_by_param,
request=request, request=request,
queryset=issue_queryset, queryset=issue_queryset,
on_results=lambda issues: issue_on_results( on_results=lambda issues: ViewIssueListSerializer(issues, many=True).data,
group_by=group_by, issues=issues, sub_group_by=sub_group_by total_count_queryset=total_issue_count,
),
) )

View file

@ -3,6 +3,7 @@ import csv
import io import io
import os import os
from datetime import date from datetime import date
import uuid
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.db import IntegrityError from django.db import IntegrityError
@ -35,6 +36,7 @@ from plane.db.models import (
Workspace, Workspace,
WorkspaceMember, WorkspaceMember,
WorkspaceTheme, WorkspaceTheme,
Profile,
) )
from plane.app.permissions import ROLE, allow_permission from plane.app.permissions import ROLE, allow_permission
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@ -42,6 +44,8 @@ from django.views.decorators.cache import cache_control
from django.views.decorators.vary import vary_on_cookie from django.views.decorators.vary import vary_on_cookie
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.license.utils.instance_value import get_configuration_value from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.workspace_seed_task import workspace_seed
from plane.utils.url import contains_url
class WorkSpaceViewSet(BaseViewSet): class WorkSpaceViewSet(BaseViewSet):
@ -108,6 +112,12 @@ class WorkSpaceViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
if contains_url(name):
return Response(
{"error": "Name cannot contain a URL"},
status=status.HTTP_400_BAD_REQUEST,
)
if serializer.is_valid(raise_exception=True): if serializer.is_valid(raise_exception=True):
serializer.save(owner=request.user) serializer.save(owner=request.user)
# Create Workspace member # Create Workspace member
@ -126,6 +136,8 @@ class WorkSpaceViewSet(BaseViewSet):
data["total_members"] = total_members data["total_members"] = total_members
data["role"] = 20 data["role"] = 20
workspace_seed.delay(serializer.data["id"])
return Response(data, status=status.HTTP_201_CREATED) return Response(data, status=status.HTTP_201_CREATED)
return Response( return Response(
[serializer.errors[error][0] for error in serializer.errors], [serializer.errors[error][0] for error in serializer.errors],
@ -147,8 +159,18 @@ class WorkSpaceViewSet(BaseViewSet):
def partial_update(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs) return super().partial_update(request, *args, **kwargs)
def remove_last_workspace_ids_from_user_settings(self, id: uuid.UUID) -> None:
"""
Remove the last workspace id from the user settings
"""
Profile.objects.filter(last_workspace_id=id).update(last_workspace_id=None)
return
@allow_permission([ROLE.ADMIN], level="WORKSPACE") @allow_permission([ROLE.ADMIN], level="WORKSPACE")
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
# Get the workspace
workspace = self.get_object()
self.remove_last_workspace_ids_from_user_settings(workspace.id)
return super().destroy(request, *args, **kwargs) return super().destroy(request, *args, **kwargs)
@ -156,8 +178,6 @@ class UserWorkSpacesEndpoint(BaseAPIView):
search_fields = ["name"] search_fields = ["name"]
filterset_fields = ["owner"] filterset_fields = ["owner"]
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def get(self, request): def get(self, request):
fields = [field for field in request.GET.get("fields", "").split(",") if field] fields = [field for field in request.GET.get("fields", "").split(",") if field]
member_count = ( member_count = (

View file

@ -12,6 +12,7 @@ from plane.app.permissions import WorkspaceViewerPermission
from plane.app.serializers.cycle import CycleSerializer from plane.app.serializers.cycle import CycleSerializer
from plane.utils.timezone_converter import user_timezone_converter from plane.utils.timezone_converter import user_timezone_converter
class WorkspaceCyclesEndpoint(BaseAPIView): class WorkspaceCyclesEndpoint(BaseAPIView):
permission_classes = [WorkspaceViewerPermission] permission_classes = [WorkspaceViewerPermission]
@ -29,6 +30,7 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False, issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True, issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
), ),
) )
) )

View file

@ -38,6 +38,7 @@ from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.host import base_host from plane.utils.host import base_host
class WorkspaceDraftIssueViewSet(BaseViewSet): class WorkspaceDraftIssueViewSet(BaseViewSet):
model = DraftIssue model = DraftIssue

View file

@ -1,5 +1,6 @@
# Django imports # Django imports
from django.db.models import Count, Q, OuterRef, Subquery, IntegerField from django.db.models import Count, Q, OuterRef, Subquery, IntegerField
from django.utils import timezone
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
# Third party modules # Third party modules
@ -133,7 +134,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# Deactivate the users from the projects where the user is part of # Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter( _ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False) ).update(is_active=False, updated_at=timezone.now())
workspace_member.is_active = False workspace_member.is_active = False
workspace_member.save() workspace_member.save()
@ -194,7 +195,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# # Deactivate the users from the projects where the user is part of # # Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter( _ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False) ).update(is_active=False, updated_at=timezone.now())
# # Deactivate the user # # Deactivate the user
workspace_member.is_active = False workspace_member.is_active = False

View file

@ -8,6 +8,7 @@ from plane.app.views.base import BaseAPIView
from plane.db.models import State from plane.db.models import State
from plane.app.permissions import WorkspaceEntityPermission from plane.app.permissions import WorkspaceEntityPermission
from plane.utils.cache import cache_response from plane.utils.cache import cache_response
from collections import defaultdict
class WorkspaceStatesEndpoint(BaseAPIView): class WorkspaceStatesEndpoint(BaseAPIView):
@ -22,5 +23,16 @@ class WorkspaceStatesEndpoint(BaseAPIView):
project__archived_at__isnull=True, project__archived_at__isnull=True,
is_triage=False, is_triage=False,
) )
grouped_states = defaultdict(list)
for state in states:
grouped_states[state.group].append(state)
for group, group_states in grouped_states.items():
count = len(group_states)
for index, state in enumerate(group_states, start=1):
state.order = index / count
serializer = StateSerializer(states, many=True).data serializer = StateSerializer(states, many=True).data
return Response(serializer, status=status.HTTP_200_OK) return Response(serializer, status=status.HTTP_200_OK)

View file

@ -27,10 +27,7 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
create_preference_keys = [] create_preference_keys = []
keys = [ keys = [key for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices]
key
for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices
]
for preference in keys: for preference in keys:
if preference not in get_preference.values_list("key", flat=True): if preference not in get_preference.values_list("key", flat=True):
@ -39,7 +36,10 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
preference = WorkspaceUserPreference.objects.bulk_create( preference = WorkspaceUserPreference.objects.bulk_create(
[ [
WorkspaceUserPreference( WorkspaceUserPreference(
key=key, user=request.user, workspace=workspace, sort_order=(65535 + (i*10000)) key=key,
user=request.user,
workspace=workspace,
sort_order=(65535 + (i * 10000)),
) )
for i, key in enumerate(create_preference_keys) for i, key in enumerate(create_preference_keys)
], ],
@ -47,10 +47,13 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
ignore_conflicts=True, ignore_conflicts=True,
) )
preferences = WorkspaceUserPreference.objects.filter( preferences = (
WorkspaceUserPreference.objects.filter(
user=request.user, workspace_id=workspace.id user=request.user, workspace_id=workspace.id
).order_by("sort_order").values("key", "is_pinned", "sort_order") )
.order_by("sort_order")
.values("key", "is_pinned", "sort_order")
)
user_preferences = {} user_preferences = {}

View file

@ -18,6 +18,7 @@ from plane.bgtasks.user_activation_email_task import user_activation_email
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.utils.ip_address import get_client_ip from plane.utils.ip_address import get_client_ip
class Adapter: class Adapter:
"""Common interface for all auth providers""" """Common interface for all auth providers"""

View file

@ -41,7 +41,6 @@ AUTHENTICATION_ERROR_CODES = {
"GOOGLE_OAUTH_PROVIDER_ERROR": 5115, "GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
"GITHUB_OAUTH_PROVIDER_ERROR": 5120, "GITHUB_OAUTH_PROVIDER_ERROR": 5120,
"GITLAB_OAUTH_PROVIDER_ERROR": 5121, "GITLAB_OAUTH_PROVIDER_ERROR": 5121,
# Reset Password # Reset Password
"INVALID_PASSWORD_TOKEN": 5125, "INVALID_PASSWORD_TOKEN": 5125,
"EXPIRED_PASSWORD_TOKEN": 5130, "EXPIRED_PASSWORD_TOKEN": 5130,

View file

@ -25,9 +25,9 @@ class GitHubOAuthProvider(OauthAdapter):
organization_scope = "read:org" organization_scope = "read:org"
def __init__(self, request, code=None, state=None, callback=None): def __init__(self, request, code=None, state=None, callback=None):
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value( GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = (
get_configuration_value(
[ [
{ {
"key": "GITHUB_CLIENT_ID", "key": "GITHUB_CLIENT_ID",
@ -43,6 +43,7 @@ class GitHubOAuthProvider(OauthAdapter):
}, },
] ]
) )
)
if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET): if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET):
raise AuthenticationException( raise AuthenticationException(
@ -128,7 +129,10 @@ class GitHubOAuthProvider(OauthAdapter):
def is_user_in_organization(self, github_username): def is_user_in_organization(self, github_username):
headers = {"Authorization": f"Bearer {self.token_data.get('access_token')}"} headers = {"Authorization": f"Bearer {self.token_data.get('access_token')}"}
response = requests.get(f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}", headers=headers) response = requests.get(
f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}",
headers=headers,
)
return response.status_code == 200 # 200 means the user is a member return response.status_code == 200 # 200 means the user is a member
def set_user_data(self): def set_user_data(self):
@ -145,7 +149,6 @@ class GitHubOAuthProvider(OauthAdapter):
error_message="GITHUB_USER_NOT_IN_ORG", error_message="GITHUB_USER_NOT_IN_ORG",
) )
email = self.__get_email(headers=headers) email = self.__get_email(headers=headers)
super().set_user_data( super().set_user_data(
{ {

View file

@ -42,11 +42,11 @@ urlpatterns = [
# credentials # credentials
path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"), path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"),
path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"), path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"),
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="sign-in"), path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"),
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="sign-in"), path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"),
# signout # signout
path("sign-out/", SignOutAuthEndpoint.as_view(), name="sign-out"), path("sign-out/", SignOutAuthEndpoint.as_view(), name="sign-out"),
path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="sign-out"), path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="space-sign-out"),
# csrf token # csrf token
path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"), path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"),
# Magic sign in # Magic sign in
@ -56,17 +56,17 @@ urlpatterns = [
path( path(
"spaces/magic-generate/", "spaces/magic-generate/",
MagicGenerateSpaceEndpoint.as_view(), MagicGenerateSpaceEndpoint.as_view(),
name="magic-generate", name="space-magic-generate",
), ),
path( path(
"spaces/magic-sign-in/", "spaces/magic-sign-in/",
MagicSignInSpaceEndpoint.as_view(), MagicSignInSpaceEndpoint.as_view(),
name="magic-sign-in", name="space-magic-sign-in",
), ),
path( path(
"spaces/magic-sign-up/", "spaces/magic-sign-up/",
MagicSignUpSpaceEndpoint.as_view(), MagicSignUpSpaceEndpoint.as_view(),
name="magic-sign-up", name="space-magic-sign-up",
), ),
## Google Oauth ## Google Oauth
path("google/", GoogleOauthInitiateEndpoint.as_view(), name="google-initiate"), path("google/", GoogleOauthInitiateEndpoint.as_view(), name="google-initiate"),
@ -74,12 +74,12 @@ urlpatterns = [
path( path(
"spaces/google/", "spaces/google/",
GoogleOauthInitiateSpaceEndpoint.as_view(), GoogleOauthInitiateSpaceEndpoint.as_view(),
name="google-initiate", name="space-google-initiate",
), ),
path( path(
"google/callback/", "spaces/google/callback/",
GoogleCallbackSpaceEndpoint.as_view(), GoogleCallbackSpaceEndpoint.as_view(),
name="google-callback", name="space-google-callback",
), ),
## Github Oauth ## Github Oauth
path("github/", GitHubOauthInitiateEndpoint.as_view(), name="github-initiate"), path("github/", GitHubOauthInitiateEndpoint.as_view(), name="github-initiate"),
@ -87,12 +87,12 @@ urlpatterns = [
path( path(
"spaces/github/", "spaces/github/",
GitHubOauthInitiateSpaceEndpoint.as_view(), GitHubOauthInitiateSpaceEndpoint.as_view(),
name="github-initiate", name="space-github-initiate",
), ),
path( path(
"spaces/github/callback/", "spaces/github/callback/",
GitHubCallbackSpaceEndpoint.as_view(), GitHubCallbackSpaceEndpoint.as_view(),
name="github-callback", name="space-github-callback",
), ),
## Gitlab Oauth ## Gitlab Oauth
path("gitlab/", GitLabOauthInitiateEndpoint.as_view(), name="gitlab-initiate"), path("gitlab/", GitLabOauthInitiateEndpoint.as_view(), name="gitlab-initiate"),
@ -100,12 +100,12 @@ urlpatterns = [
path( path(
"spaces/gitlab/", "spaces/gitlab/",
GitLabOauthInitiateSpaceEndpoint.as_view(), GitLabOauthInitiateSpaceEndpoint.as_view(),
name="gitlab-initiate", name="space-gitlab-initiate",
), ),
path( path(
"spaces/gitlab/callback/", "spaces/gitlab/callback/",
GitLabCallbackSpaceEndpoint.as_view(), GitLabCallbackSpaceEndpoint.as_view(),
name="gitlab-callback", name="space-gitlab-callback",
), ),
# Email Check # Email Check
path("email-check/", EmailCheckEndpoint.as_view(), name="email-check"), path("email-check/", EmailCheckEndpoint.as_view(), name="email-check"),
@ -120,12 +120,12 @@ urlpatterns = [
path( path(
"spaces/forgot-password/", "spaces/forgot-password/",
ForgotPasswordSpaceEndpoint.as_view(), ForgotPasswordSpaceEndpoint.as_view(),
name="forgot-password", name="space-forgot-password",
), ),
path( path(
"spaces/reset-password/<uidb64>/<token>/", "spaces/reset-password/<uidb64>/<token>/",
ResetPasswordSpaceEndpoint.as_view(), ResetPasswordSpaceEndpoint.as_view(),
name="forgot-password", name="space-forgot-password",
), ),
path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"), path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"),
path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"), path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"),

View file

@ -1,30 +1,53 @@
# Django imports # Django imports
from django.conf import settings from django.conf import settings
from django.http import HttpRequest from django.http import HttpRequest
# Third party imports # Third party imports
from rest_framework.request import Request from rest_framework.request import Request
# Module imports # Module imports
from plane.utils.ip_address import get_client_ip from plane.utils.ip_address import get_client_ip
def base_host(request: Request | HttpRequest, is_admin: bool = False, is_space: bool = False, is_app: bool = False) -> str:
def base_host(
request: Request | HttpRequest,
is_admin: bool = False,
is_space: bool = False,
is_app: bool = False,
) -> str:
"""Utility function to return host / origin from the request""" """Utility function to return host / origin from the request"""
# Calculate the base origin from request # Calculate the base origin from request
base_origin = settings.WEB_URL or settings.APP_BASE_URL base_origin = settings.WEB_URL or settings.APP_BASE_URL
# Admin redirections # Admin redirection
if is_admin: if is_admin:
if settings.ADMIN_BASE_URL: admin_base_path = getattr(settings, "ADMIN_BASE_PATH", None)
return settings.ADMIN_BASE_URL if not isinstance(admin_base_path, str):
else: admin_base_path = "/god-mode/"
return base_origin + "/god-mode/" if not admin_base_path.startswith("/"):
admin_base_path = "/" + admin_base_path
if not admin_base_path.endswith("/"):
admin_base_path += "/"
# Space redirections if settings.ADMIN_BASE_URL:
if is_space: return settings.ADMIN_BASE_URL + admin_base_path
if settings.SPACE_BASE_URL:
return settings.SPACE_BASE_URL
else: else:
return base_origin + "/spaces/" return base_origin + admin_base_path
# Space redirection
if is_space:
space_base_path = getattr(settings, "SPACE_BASE_PATH", None)
if not isinstance(space_base_path, str):
space_base_path = "/spaces/"
if not space_base_path.startswith("/"):
space_base_path = "/" + space_base_path
if not space_base_path.endswith("/"):
space_base_path += "/"
if settings.SPACE_BASE_URL:
return settings.SPACE_BASE_URL + space_base_path
else:
return base_origin + space_base_path
# App Redirection # App Redirection
if is_app: if is_app:

View file

@ -6,6 +6,7 @@ from django.conf import settings
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.utils.ip_address import get_client_ip from plane.utils.ip_address import get_client_ip
def user_login(request, user, is_app=False, is_admin=False, is_space=False): def user_login(request, user, is_app=False, is_admin=False, is_space=False):
login(request=request, user=user) login(request=request, user=user)

View file

@ -21,6 +21,7 @@ from plane.authentication.adapter.error import (
) )
from plane.utils.path_validator import validate_next_path from plane.utils.path_validator import validate_next_path
class SignInAuthEndpoint(View): class SignInAuthEndpoint(View):
def post(self, request): def post(self, request):
next_path = request.POST.get("next_path") next_path = request.POST.get("next_path")

View file

@ -18,6 +18,7 @@ from plane.authentication.adapter.error import (
) )
from plane.utils.path_validator import validate_next_path from plane.utils.path_validator import validate_next_path
class GitHubOauthInitiateEndpoint(View): class GitHubOauthInitiateEndpoint(View):
def get(self, request): def get(self, request):
# Get host and next path # Get host and next path

View file

@ -18,6 +18,7 @@ from plane.authentication.adapter.error import (
) )
from plane.utils.path_validator import validate_next_path from plane.utils.path_validator import validate_next_path
class GitLabOauthInitiateEndpoint(View): class GitLabOauthInitiateEndpoint(View):
def get(self, request): def get(self, request):
# Get host and next path # Get host and next path

View file

@ -20,6 +20,7 @@ from plane.authentication.adapter.error import (
) )
from plane.utils.path_validator import validate_next_path from plane.utils.path_validator import validate_next_path
class GoogleOauthInitiateEndpoint(View): class GoogleOauthInitiateEndpoint(View):
def get(self, request): def get(self, request):
request.session["host"] = base_host(request=request, is_app=True) request.session["host"] = base_host(request=request, is_app=True)
@ -95,7 +96,9 @@ class GoogleCallbackEndpoint(View):
# Get the redirection path # Get the redirection path
path = get_redirection_path(user=user) path = get_redirection_path(user=user)
# redirect to referer path # redirect to referer path
url = urljoin(base_host, str(validate_next_path(next_path)) if next_path else path) url = urljoin(
base_host, str(validate_next_path(next_path)) if next_path else path
)
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
except AuthenticationException as e: except AuthenticationException as e:
params = e.get_error_dict() params = e.get_error_dict()

View file

@ -53,7 +53,9 @@ class ChangePasswordEndpoint(APIView):
error_message="MISSING_PASSWORD", error_message="MISSING_PASSWORD",
payload={"error": "Old password is missing"}, payload={"error": "Old password is missing"},
) )
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) return Response(
exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST
)
# Get the new password # Get the new password
new_password = request.data.get("new_password", False) new_password = request.data.get("new_password", False)
@ -66,7 +68,6 @@ class ChangePasswordEndpoint(APIView):
) )
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
# If the user password is not autoset then we need to check the old passwords # If the user password is not autoset then we need to check the old passwords
if not user.is_password_autoset and not user.check_password(old_password): if not user.is_password_autoset and not user.check_password(old_password):
exc = AuthenticationException( exc = AuthenticationException(

View file

@ -25,6 +25,7 @@ from plane.authentication.adapter.error import (
) )
from plane.utils.path_validator import validate_next_path from plane.utils.path_validator import validate_next_path
class MagicGenerateSpaceEndpoint(APIView): class MagicGenerateSpaceEndpoint(APIView):
permission_classes = [AllowAny] permission_classes = [AllowAny]
@ -38,7 +39,6 @@ class MagicGenerateSpaceEndpoint(APIView):
) )
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
email = request.data.get("email", "").strip().lower() email = request.data.get("email", "").strip().lower()
try: try:
validate_email(email) validate_email(email)

View file

@ -459,8 +459,37 @@ def analytic_export_task(email, data, slug):
csv_buffer = generate_csv_from_rows(rows) csv_buffer = generate_csv_from_rows(rows)
send_export_email(email, slug, csv_buffer, rows) send_export_email(email, slug, csv_buffer, rows)
logging.getLogger("plane").info("Email sent succesfully.") logging.getLogger("plane.worker").info("Email sent successfully.")
return return
except Exception as e: except Exception as e:
log_exception(e) log_exception(e)
return return
@shared_task
def export_analytics_to_csv_email(data, headers, keys, email, slug):
try:
"""
Prepares a CSV from data and sends it as an email attachment.
Parameters:
- data: List of dictionaries (e.g. from .values())
- headers: List of CSV column headers
- keys: Keys to extract from each data item (dict)
- email: Email address to send to
- slug: Used for the filename
"""
# Prepare rows: header + data rows
rows = [headers]
for item in data:
row = [item.get(key, "") for key in keys]
rows.append(row)
# Generate CSV buffer
csv_buffer = generate_csv_from_rows(rows)
# Send email with CSV attachment
send_export_email(email=email, slug=slug, csv_buffer=csv_buffer, rows=rows)
except Exception as e:
log_exception(e)
return

View file

@ -12,6 +12,7 @@ from plane.db.models import FileAsset, Page, Issue
from plane.utils.exception_logger import log_exception from plane.utils.exception_logger import log_exception
from plane.settings.storage import S3Storage from plane.settings.storage import S3Storage
from celery import shared_task from celery import shared_task
from plane.utils.url import normalize_url_path
def get_entity_id_field(entity_type, entity_id): def get_entity_id_field(entity_type, entity_id):
@ -67,11 +68,14 @@ def sync_with_external_service(entity_name, description_html):
"description_html": description_html, "description_html": description_html,
"variant": "rich" if entity_name == "PAGE" else "document", "variant": "rich" if entity_name == "PAGE" else "document",
} }
response = requests.post(
f"{settings.LIVE_BASE_URL}/convert-document/", live_url = settings.LIVE_URL
json=data, if not live_url:
headers=None, return {}
)
url = normalize_url_path(f"{live_url}/convert-document/")
response = requests.post(url, json=data, headers=None)
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
except requests.RequestException as e: except requests.RequestException as e:

View file

@ -33,6 +33,7 @@ from plane.db.models import (
Intake, Intake,
IntakeIssue, IntakeIssue,
) )
from plane.db.models.intake import SourceType
def create_project(workspace, user_id): def create_project(workspace, user_id):
@ -388,7 +389,7 @@ def create_intake_issues(workspace, project, user_id, intake_issue_count):
if status == 0 if status == 0
else None else None
), ),
source="in-app", source=SourceType.IN_APP,
workspace=workspace, workspace=workspace,
project=project, project=project,
) )

View file

@ -284,6 +284,7 @@ def send_email_notification(
"project": str(issue.project.name), "project": str(issue.project.name),
"user_preference": f"{base_api}/profile/preferences/email", "user_preference": f"{base_api}/profile/preferences/email",
"comments": comments, "comments": comments,
"entity_type": "issue",
} }
html_content = render_to_string( html_content = render_to_string(
"emails/notifications/issue-updates.html", context "emails/notifications/issue-updates.html", context
@ -309,7 +310,7 @@ def send_email_notification(
) )
msg.attach_alternative(html_content, "text/html") msg.attach_alternative(html_content, "text/html")
msg.send() msg.send()
logging.getLogger("plane").info("Email Sent Successfully") logging.getLogger("plane.worker").info("Email Sent Successfully")
# Update the logs # Update the logs
EmailNotificationLog.objects.filter( EmailNotificationLog.objects.filter(
@ -325,7 +326,7 @@ def send_email_notification(
release_lock(lock_id=lock_id) release_lock(lock_id=lock_id)
return return
else: else:
logging.getLogger("plane").info("Duplicate email received skipping") logging.getLogger("plane.worker").info("Duplicate email received skipping")
return return
except (Issue.DoesNotExist, User.DoesNotExist): except (Issue.DoesNotExist, User.DoesNotExist):
release_lock(lock_id=lock_id) release_lock(lock_id=lock_id)

View file

@ -3,34 +3,49 @@ import csv
import io import io
import json import json
import zipfile import zipfile
from typing import List
import boto3 import boto3
from botocore.client import Config from botocore.client import Config
from uuid import UUID
from datetime import datetime, date
# Third party imports # Third party imports
from celery import shared_task from celery import shared_task
# Django imports # Django imports
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from openpyxl import Workbook from openpyxl import Workbook
from django.db.models import F, Prefetch
from collections import defaultdict
# Module imports # Module imports
from plane.db.models import ExporterHistory, Issue from plane.db.models import ExporterHistory, Issue, FileAsset, Label, User, IssueComment
from plane.utils.exception_logger import log_exception from plane.utils.exception_logger import log_exception
def dateTimeConverter(time): def dateTimeConverter(time: datetime) -> str | None:
"""
Convert a datetime object to a formatted string.
"""
if time: if time:
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z") return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
def dateConverter(time): def dateConverter(time: date) -> str | None:
"""
Convert a date object to a formatted string.
"""
if time: if time:
return time.strftime("%a, %d %b %Y") return time.strftime("%a, %d %b %Y")
def create_csv_file(data): def create_csv_file(data: List[List[str]]) -> str:
"""
Create a CSV file from the provided data.
"""
csv_buffer = io.StringIO() csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL) csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
@ -41,11 +56,17 @@ def create_csv_file(data):
return csv_buffer.getvalue() return csv_buffer.getvalue()
def create_json_file(data): def create_json_file(data: List[dict]) -> str:
"""
Create a JSON file from the provided data.
"""
return json.dumps(data) return json.dumps(data)
def create_xlsx_file(data): def create_xlsx_file(data: List[List[str]]) -> bytes:
"""
Create an XLSX file from the provided data.
"""
workbook = Workbook() workbook = Workbook()
sheet = workbook.active sheet = workbook.active
@ -58,7 +79,10 @@ def create_xlsx_file(data):
return xlsx_buffer.getvalue() return xlsx_buffer.getvalue()
def create_zip_file(files): def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO:
"""
Create a ZIP file from the provided files.
"""
zip_buffer = io.BytesIO() zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf: with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
for filename, file_content in files: for filename, file_content in files:
@ -68,7 +92,13 @@ def create_zip_file(files):
return zip_buffer return zip_buffer
def upload_to_s3(zip_file, workspace_id, token_id, slug): # TODO: Change the upload_to_s3 function to use the new storage method with entry in file asset table
def upload_to_s3(
zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug: str
) -> None:
"""
Upload a ZIP file to S3 and generate a presigned URL.
"""
file_name = ( file_name = (
f"{workspace_id}/export-{slug}-{token_id[:6]}-{str(timezone.now().date())}.zip" f"{workspace_id}/export-{slug}-{token_id[:6]}-{str(timezone.now().date())}.zip"
) )
@ -150,75 +180,85 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
exporter_instance.save(update_fields=["status", "url", "key"]) exporter_instance.save(update_fields=["status", "url", "key"])
def generate_table_row(issue): def generate_table_row(issue: dict) -> List[str]:
"""
Generate a table row from an issue dictionary.
"""
return [ return [
f"""{issue["project__identifier"]}-{issue["sequence_id"]}""", f"""{issue["project_identifier"]}-{issue["sequence_id"]}""",
issue["project__name"], issue["project_name"],
issue["name"], issue["name"],
issue["description_stripped"], issue["description"],
issue["state__name"], issue["state_name"],
dateConverter(issue["start_date"]), dateConverter(issue["start_date"]),
dateConverter(issue["target_date"]), dateConverter(issue["target_date"]),
issue["priority"], issue["priority"],
( issue["created_by"],
f"{issue['created_by__first_name']} {issue['created_by__last_name']}" ", ".join(issue["labels"]) if issue["labels"] else "",
if issue["created_by__first_name"] and issue["created_by__last_name"] issue["cycle_name"],
else "" issue["cycle_start_date"],
), issue["cycle_end_date"],
( ", ".join(issue.get("module_name", "")) if issue.get("module_name") else "",
f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
if issue["assignees__first_name"] and issue["assignees__last_name"]
else ""
),
issue["labels__name"] if issue["labels__name"] else "",
issue["issue_cycle__cycle__name"],
dateConverter(issue["issue_cycle__cycle__start_date"]),
dateConverter(issue["issue_cycle__cycle__end_date"]),
issue["issue_module__module__name"],
dateConverter(issue["issue_module__module__start_date"]),
dateConverter(issue["issue_module__module__target_date"]),
dateTimeConverter(issue["created_at"]), dateTimeConverter(issue["created_at"]),
dateTimeConverter(issue["updated_at"]), dateTimeConverter(issue["updated_at"]),
dateTimeConverter(issue["completed_at"]), dateTimeConverter(issue["completed_at"]),
dateTimeConverter(issue["archived_at"]), dateTimeConverter(issue["archived_at"]),
(
", ".join(
[
f"{comment['comment']} ({comment['created_at']} by {comment['created_by']})"
for comment in issue["comments"]
]
)
if issue["comments"]
else ""
),
issue["estimate"] if issue["estimate"] else "",
", ".join(issue["link"]) if issue["link"] else "",
", ".join(issue["assignees"]) if issue["assignees"] else "",
issue["subscribers_count"] if issue["subscribers_count"] else "",
issue["attachment_count"] if issue["attachment_count"] else "",
", ".join(issue["attachment_links"]) if issue["attachment_links"] else "",
] ]
def generate_json_row(issue): def generate_json_row(issue: dict) -> dict:
"""
Generate a JSON row from an issue dictionary.
"""
return { return {
"ID": f"""{issue["project__identifier"]}-{issue["sequence_id"]}""", "ID": f"""{issue["project_identifier"]}-{issue["sequence_id"]}""",
"Project": issue["project__name"], "Project": issue["project_name"],
"Name": issue["name"], "Name": issue["name"],
"Description": issue["description_stripped"], "Description": issue["description"],
"State": issue["state__name"], "State": issue["state_name"],
"Start Date": dateConverter(issue["start_date"]), "Start Date": dateConverter(issue["start_date"]),
"Target Date": dateConverter(issue["target_date"]), "Target Date": dateConverter(issue["target_date"]),
"Priority": issue["priority"], "Priority": issue["priority"],
"Created By": ( "Created By": (f"{issue['created_by']}" if issue["created_by"] else ""),
f"{issue['created_by__first_name']} {issue['created_by__last_name']}" "Assignee": issue["assignees"],
if issue["created_by__first_name"] and issue["created_by__last_name"] "Labels": issue["labels"],
else "" "Cycle Name": issue["cycle_name"],
), "Cycle Start Date": issue["cycle_start_date"],
"Assignee": ( "Cycle End Date": issue["cycle_end_date"],
f"{issue['assignees__first_name']} {issue['assignees__last_name']}" "Module Name": issue["module_name"],
if issue["assignees__first_name"] and issue["assignees__last_name"]
else ""
),
"Labels": issue["labels__name"] if issue["labels__name"] else "",
"Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"],
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
"Created At": dateTimeConverter(issue["created_at"]), "Created At": dateTimeConverter(issue["created_at"]),
"Updated At": dateTimeConverter(issue["updated_at"]), "Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_at"]), "Completed At": dateTimeConverter(issue["completed_at"]),
"Archived At": dateTimeConverter(issue["archived_at"]), "Archived At": dateTimeConverter(issue["archived_at"]),
"Comments": issue["comments"],
"Estimate": issue["estimate"],
"Link": issue["link"],
"Subscribers Count": issue["subscribers_count"],
"Attachment Count": issue["attachment_count"],
"Attachment Links": issue["attachment_links"],
} }
def update_json_row(rows, row): def update_json_row(rows: List[dict], row: dict) -> None:
"""
Update the json row with the new assignee and label.
"""
matched_index = next( matched_index = next(
( (
index index
@ -247,7 +287,10 @@ def update_json_row(rows, row):
rows.append(row) rows.append(row)
def update_table_row(rows, row): def update_table_row(rows: List[List[str]], row: List[str]) -> None:
"""
Update the table row with the new assignee and label.
"""
matched_index = next( matched_index = next(
(index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]), (index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]),
None, None,
@ -269,7 +312,12 @@ def update_table_row(rows, row):
rows.append(row) rows.append(row)
def generate_csv(header, project_id, issues, files): def generate_csv(
header: List[str],
project_id: str,
issues: List[dict],
files: List[tuple[str, str | bytes]],
) -> None:
""" """
Generate CSV export for all the passed issues. Generate CSV export for all the passed issues.
""" """
@ -281,7 +329,15 @@ def generate_csv(header, project_id, issues, files):
files.append((f"{project_id}.csv", csv_file)) files.append((f"{project_id}.csv", csv_file))
def generate_json(header, project_id, issues, files): def generate_json(
header: List[str],
project_id: str,
issues: List[dict],
files: List[tuple[str, str | bytes]],
) -> None:
"""
Generate JSON export for all the passed issues.
"""
rows = [] rows = []
for issue in issues: for issue in issues:
row = generate_json_row(issue) row = generate_json_row(issue)
@ -290,24 +346,55 @@ def generate_json(header, project_id, issues, files):
files.append((f"{project_id}.json", json_file)) files.append((f"{project_id}.json", json_file))
def generate_xlsx(header, project_id, issues, files): def generate_xlsx(
header: List[str],
project_id: str,
issues: List[dict],
files: List[tuple[str, str | bytes]],
) -> None:
"""
Generate XLSX export for all the passed issues.
"""
rows = [header] rows = [header]
for issue in issues: for issue in issues:
row = generate_table_row(issue) row = generate_table_row(issue)
update_table_row(rows, row) update_table_row(rows, row)
xlsx_file = create_xlsx_file(rows) xlsx_file = create_xlsx_file(rows)
files.append((f"{project_id}.xlsx", xlsx_file)) files.append((f"{project_id}.xlsx", xlsx_file))
def get_created_by(obj: Issue | IssueComment) -> str:
"""
Get the created by user for the given object.
"""
if obj.created_by:
return f"{obj.created_by.first_name} {obj.created_by.last_name}"
return ""
@shared_task @shared_task
def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug): def issue_export_task(
provider: str,
workspace_id: UUID,
project_ids: List[str],
token_id: str,
multiple: bool,
slug: str,
):
"""
Export issues from the workspace.
provider (str): The provider to export the issues to csv | json | xlsx.
token_id (str): The export object token id.
multiple (bool): Whether to export the issues to multiple files per project.
"""
try: try:
exporter_instance = ExporterHistory.objects.get(token=token_id) exporter_instance = ExporterHistory.objects.get(token=token_id)
exporter_instance.status = "processing" exporter_instance.status = "processing"
exporter_instance.save(update_fields=["status"]) exporter_instance.save(update_fields=["status"])
# Base query to get the issues
workspace_issues = ( workspace_issues = (
(
Issue.objects.filter( Issue.objects.filter(
workspace__id=workspace_id, workspace__id=workspace_id,
project_id__in=project_ids, project_id__in=project_ids,
@ -315,42 +402,112 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
project__project_projectmember__is_active=True, project__project_projectmember__is_active=True,
project__archived_at__isnull=True, project__archived_at__isnull=True,
) )
.select_related("project", "workspace", "state", "parent", "created_by") .select_related(
"project",
"workspace",
"state",
"parent",
"created_by",
"estimate_point",
)
.prefetch_related( .prefetch_related(
"assignees", "labels", "issue_cycle__cycle", "issue_module__module" "labels",
) "issue_cycle__cycle",
.values( "issue_module__module",
"id", "issue_comments",
"project__identifier", "assignees",
"project__name", Prefetch(
"project__id", "assignees",
"sequence_id", queryset=User.objects.only("first_name", "last_name").distinct(),
"name", to_attr="assignee_details",
"description_stripped", ),
"priority", Prefetch(
"start_date", "labels",
"target_date", queryset=Label.objects.only("name").distinct(),
"state__name", to_attr="label_details",
"created_at", ),
"updated_at", "issue_subscribers",
"completed_at", "issue_link",
"archived_at",
"issue_cycle__cycle__name",
"issue_cycle__cycle__start_date",
"issue_cycle__cycle__end_date",
"issue_module__module__name",
"issue_module__module__start_date",
"issue_module__module__target_date",
"created_by__first_name",
"created_by__last_name",
"assignees__first_name",
"assignees__last_name",
"labels__name",
) )
) )
.order_by("project__identifier", "sequence_id")
.distinct() # Get the attachments for the issues
) file_assets = FileAsset.objects.filter(
issue_id__in=workspace_issues.values_list("id", flat=True),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
).annotate(work_item_id=F("issue_id"), asset_id=F("id"))
# Create a dictionary to store the attachments for the issues
attachment_dict = defaultdict(list)
for asset in file_assets:
attachment_dict[asset.work_item_id].append(asset.asset_id)
# Create a list to store the issues data
issues_data = []
# Iterate over the issues
for issue in workspace_issues:
attachments = attachment_dict.get(issue.id, [])
issue_data = {
"id": issue.id,
"project_identifier": issue.project.identifier,
"project_name": issue.project.name,
"project_id": issue.project.id,
"sequence_id": issue.sequence_id,
"name": issue.name,
"description": issue.description_stripped,
"priority": issue.priority,
"start_date": issue.start_date,
"target_date": issue.target_date,
"state_name": issue.state.name if issue.state else None,
"created_at": issue.created_at,
"updated_at": issue.updated_at,
"completed_at": issue.completed_at,
"archived_at": issue.archived_at,
"module_name": [
module.module.name for module in issue.issue_module.all()
],
"created_by": get_created_by(issue),
"labels": [label.name for label in issue.label_details],
"comments": [
{
"comment": comment.comment_stripped,
"created_at": dateConverter(comment.created_at),
"created_by": get_created_by(comment),
}
for comment in issue.issue_comments.all()
],
"estimate": issue.estimate_point.value
if issue.estimate_point and issue.estimate_point.value
else "",
"link": [link.url for link in issue.issue_link.all()],
"assignees": [
f"{assignee.first_name} {assignee.last_name}"
for assignee in issue.assignee_details
],
"subscribers_count": issue.issue_subscribers.count(),
"attachment_count": len(attachments),
"attachment_links": [
f"/api/assets/v2/workspaces/{issue.workspace.slug}/projects/{issue.project_id}/issues/{issue.id}/attachments/{asset}/"
for asset in attachments
],
}
# Get Cycles data for the issue
cycle = issue.issue_cycle.last()
if cycle:
# Update cycle data
issue_data["cycle_name"] = cycle.cycle.name
issue_data["cycle_start_date"] = dateConverter(cycle.cycle.start_date)
issue_data["cycle_end_date"] = dateConverter(cycle.cycle.end_date)
else:
issue_data["cycle_name"] = ""
issue_data["cycle_start_date"] = ""
issue_data["cycle_end_date"] = ""
issues_data.append(issue_data)
# CSV header # CSV header
header = [ header = [
"ID", "ID",
@ -362,20 +519,25 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
"Target Date", "Target Date",
"Priority", "Priority",
"Created By", "Created By",
"Assignee",
"Labels", "Labels",
"Cycle Name", "Cycle Name",
"Cycle Start Date", "Cycle Start Date",
"Cycle End Date", "Cycle End Date",
"Module Name", "Module Name",
"Module Start Date",
"Module Target Date",
"Created At", "Created At",
"Updated At", "Updated At",
"Completed At", "Completed At",
"Archived At", "Archived At",
"Comments",
"Estimate",
"Link",
"Assignees",
"Subscribers Count",
"Attachment Count",
"Attachment Links",
] ]
# Map the provider to the function
EXPORTER_MAPPER = { EXPORTER_MAPPER = {
"csv": generate_csv, "csv": generate_csv,
"json": generate_json, "json": generate_json,
@ -384,8 +546,13 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
files = [] files = []
if multiple: if multiple:
project_dict = defaultdict(list)
for issue in issues_data:
project_dict[str(issue["project_id"])].append(issue)
for project_id in project_ids: for project_id in project_ids:
issues = workspace_issues.filter(project__id=project_id) issues = project_dict.get(str(project_id), [])
exporter = EXPORTER_MAPPER.get(provider) exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None: if exporter is not None:
exporter(header, project_id, issues, files) exporter(header, project_id, issues, files)
@ -393,7 +560,7 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
else: else:
exporter = EXPORTER_MAPPER.get(provider) exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None: if exporter is not None:
exporter(header, workspace_id, workspace_issues, files) exporter(header, workspace_id, issues_data, files)
zip_buffer = create_zip_file(files) zip_buffer = create_zip_file(files)
upload_to_s3(zip_buffer, workspace_id, token_id, slug) upload_to_s3(zip_buffer, workspace_id, token_id, slug)

View file

@ -63,7 +63,7 @@ def forgot_password(first_name, email, uidb64, token, current_site):
) )
msg.attach_alternative(html_content, "text/html") msg.attach_alternative(html_content, "text/html")
msg.send() msg.send()
logging.getLogger("plane").info("Email sent successfully") logging.getLogger("plane.worker").info("Email sent successfully")
return return
except Exception as e: except Exception as e:
log_exception(e) log_exception(e)

View file

@ -1650,40 +1650,6 @@ def issue_activity(
# Save all the values to database # Save all the values to database
issue_activities_created = IssueActivity.objects.bulk_create(issue_activities) issue_activities_created = IssueActivity.objects.bulk_create(issue_activities)
# Post the updates to segway for integrations and webhooks
if len(issue_activities_created):
for activity in issue_activities_created:
webhook_activity.delay(
event=(
"issue_comment"
if activity.field == "comment"
else "intake_issue"
if intake
else "issue"
),
event_id=(
activity.issue_comment_id
if activity.field == "comment"
else intake
if intake
else activity.issue_id
),
verb=activity.verb,
field=(
"description" if activity.field == "comment" else activity.field
),
old_value=(
activity.old_value if activity.old_value != "" else None
),
new_value=(
activity.new_value if activity.new_value != "" else None
),
actor_id=activity.actor_id,
current_site=origin,
slug=activity.workspace.slug,
old_identifier=activity.old_identifier,
new_identifier=activity.new_identifier,
)
if notification: if notification:
notifications.delay( notifications.delay(

View file

@ -53,7 +53,7 @@ def magic_link(email, key, token):
) )
msg.attach_alternative(html_content, "text/html") msg.attach_alternative(html_content, "text/html")
msg.send() msg.send()
logging.getLogger("plane").info("Email sent successfully.") logging.getLogger("plane.worker").info("Email sent successfully.")
return return
except Exception as e: except Exception as e:
log_exception(e) log_exception(e)

View file

@ -80,7 +80,7 @@ def project_add_user_email(current_site, project_member_id, invitor_id):
# Send the email # Send the email
msg.send() msg.send()
# Log the success # Log the success
logging.getLogger("plane").info("Email sent successfully.") logging.getLogger("plane.worker").info("Email sent successfully.")
return return
except Exception as e: except Exception as e:
log_exception(e) log_exception(e)

View file

@ -76,7 +76,7 @@ def project_invitation(email, project_id, token, current_site, invitor):
msg.attach_alternative(html_content, "text/html") msg.attach_alternative(html_content, "text/html")
msg.send() msg.send()
logging.getLogger("plane").info("Email sent successfully.") logging.getLogger("plane.worker").info("Email sent successfully.")
return return
except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist): except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist):
return return

View file

@ -58,7 +58,7 @@ def user_activation_email(current_site, user_id):
msg.attach_alternative(html_content, "text/html") msg.attach_alternative(html_content, "text/html")
msg.send() msg.send()
logging.getLogger("plane").info("Email sent successfully.") logging.getLogger("plane.worker").info("Email sent successfully.")
return return
except Exception as e: except Exception as e:
log_exception(e) log_exception(e)

View file

@ -60,7 +60,7 @@ def user_deactivation_email(current_site, user_id):
# Attach HTML content # Attach HTML content
msg.attach_alternative(html_content, "text/html") msg.attach_alternative(html_content, "text/html")
msg.send() msg.send()
logging.getLogger("plane").info("Email sent successfully.") logging.getLogger("plane.worker").info("Email sent successfully.")
return return
except Exception as e: except Exception as e:
log_exception(e) log_exception(e)

View file

@ -5,6 +5,7 @@ import logging
import uuid import uuid
import requests import requests
from typing import Any, Dict, List, Optional, Union
# Third party imports # Third party imports
from celery import shared_task from celery import shared_task
@ -70,123 +71,60 @@ MODEL_MAPPER = {
} }
def get_model_data(event, event_id, many=False): logger = logging.getLogger("plane.worker")
def get_model_data(
event: str, event_id: Union[str, List[str]], many: bool = False
) -> Dict[str, Any]:
"""
Retrieve and serialize model data based on the event type.
Args:
event (str): The type of event/model to retrieve data for
event_id (Union[str, List[str]]): The ID or list of IDs of the model instance(s)
many (bool): Whether to retrieve multiple instances
Returns:
Dict[str, Any]: Serialized model data
Raises:
ValueError: If serializer is not found for the event
ObjectDoesNotExist: If model instance is not found
"""
model = MODEL_MAPPER.get(event) model = MODEL_MAPPER.get(event)
if model is None:
raise ValueError(f"Model not found for event: {event}")
try:
if many: if many:
queryset = model.objects.filter(pk__in=event_id) queryset = model.objects.filter(pk__in=event_id)
else: else:
queryset = model.objects.get(pk=event_id) queryset = model.objects.get(pk=event_id)
serializer = SERIALIZER_MAPPER.get(event) serializer = SERIALIZER_MAPPER.get(event)
if serializer is None:
raise ValueError(f"Serializer not found for event: {event}")
return serializer(queryset, many=many).data return serializer(queryset, many=many).data
except ObjectDoesNotExist:
raise ObjectDoesNotExist(f"No {event} found with id: {event_id}")
@shared_task(
bind=True,
autoretry_for=(requests.RequestException,),
retry_backoff=600,
max_retries=5,
retry_jitter=True,
)
def webhook_task(self, webhook, slug, event, event_data, action, current_site):
try:
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
headers = {
"Content-Type": "application/json",
"User-Agent": "Autopilot",
"X-Plane-Delivery": str(uuid.uuid4()),
"X-Plane-Event": event,
}
# # Your secret key
event_data = (
json.loads(json.dumps(event_data, cls=DjangoJSONEncoder))
if event_data is not None
else None
)
action = {
"POST": "create",
"PATCH": "update",
"PUT": "update",
"DELETE": "delete",
}.get(action, action)
payload = {
"event": event,
"action": action,
"webhook_id": str(webhook.id),
"workspace_id": str(webhook.workspace_id),
"data": event_data,
}
# Use HMAC for generating signature
if webhook.secret_key:
hmac_signature = hmac.new(
webhook.secret_key.encode("utf-8"),
json.dumps(payload).encode("utf-8"),
hashlib.sha256,
)
signature = hmac_signature.hexdigest()
headers["X-Plane-Signature"] = signature
# Send the webhook event
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)
# Log the webhook request
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
request_body=str(payload),
response_status=str(response.status_code),
response_headers=str(response.headers),
response_body=str(response.text),
retry_count=str(self.request.retries),
)
except Webhook.DoesNotExist:
return
except requests.RequestException as e:
# Log the failed webhook request
WebhookLog.objects.create(
workspace_id=str(webhook.workspace_id),
webhook=str(webhook.id),
event_type=str(event),
request_method=str(action),
request_headers=str(headers),
request_body=str(payload),
response_status=500,
response_headers="",
response_body=str(e),
retry_count=str(self.request.retries),
)
# Retry logic
if self.request.retries >= self.max_retries:
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
if webhook:
# send email for the deactivation of the webhook
send_webhook_deactivation_email(
webhook_id=webhook.id,
receiver_id=webhook.created_by_id,
reason=str(e),
current_site=current_site,
)
return
raise requests.RequestException()
except Exception as e:
if settings.DEBUG:
print(e)
log_exception(e)
return
@shared_task @shared_task
def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reason): def send_webhook_deactivation_email(
# Get email configurations webhook_id: str, receiver_id: str, current_site: str, reason: str
) -> None:
"""
Send an email notification when a webhook is deactivated.
Args:
webhook_id (str): ID of the deactivated webhook
receiver_id (str): ID of the user to receive the notification
current_site (str): Current site URL
reason (str): Reason for webhook deactivation
"""
try:
( (
EMAIL_HOST, EMAIL_HOST,
EMAIL_HOST_USER, EMAIL_HOST_USER,
@ -199,6 +137,8 @@ def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reaso
receiver = User.objects.get(pk=receiver_id) receiver = User.objects.get(pk=receiver_id)
webhook = Webhook.objects.get(pk=webhook_id) webhook = Webhook.objects.get(pk=webhook_id)
# Get the webhook payload
subject = "Webhook Deactivated" subject = "Webhook Deactivated"
message = f"Webhook {webhook.url} has been deactivated due to failed requests." message = f"Webhook {webhook.url} has been deactivated due to failed requests."
@ -213,7 +153,7 @@ def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reaso
) )
text_content = strip_tags(html_content) text_content = strip_tags(html_content)
try: # Set the email connection
connection = get_connection( connection = get_connection(
host=EMAIL_HOST, host=EMAIL_HOST,
port=int(EMAIL_PORT), port=int(EMAIL_PORT),
@ -223,6 +163,7 @@ def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reaso
use_ssl=EMAIL_USE_SSL == "1", use_ssl=EMAIL_USE_SSL == "1",
) )
# Create the email message
msg = EmailMultiAlternatives( msg = EmailMultiAlternatives(
subject=subject, subject=subject,
body=text_content, body=text_content,
@ -232,11 +173,10 @@ def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reaso
) )
msg.attach_alternative(html_content, "text/html") msg.attach_alternative(html_content, "text/html")
msg.send() msg.send()
logging.getLogger("plane").info("Email sent successfully.") logger.info("Email sent successfully.")
return
except Exception as e: except Exception as e:
log_exception(e) log_exception(e)
return logger.error(f"Failed to send email: {e}")
@shared_task( @shared_task(
@ -247,10 +187,29 @@ def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reaso
retry_jitter=True, retry_jitter=True,
) )
def webhook_send_task( def webhook_send_task(
self, webhook, slug, event, event_data, action, current_site, activity self,
): webhook_id: str,
slug: str,
event: str,
event_data: Optional[Dict[str, Any]],
action: str,
current_site: str,
activity: Optional[Dict[str, Any]],
) -> None:
"""
Send webhook notifications to configured endpoints.
Args:
webhook (str): Webhook ID
slug (str): Workspace slug
event (str): Event type
event_data (Optional[Dict[str, Any]]): Event data to be sent
action (str): HTTP method/action
current_site (str): Current site URL
activity (Optional[Dict[str, Any]]): Activity data
"""
try: try:
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug) webhook = Webhook.objects.get(id=webhook_id, workspace__slug=slug)
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -297,7 +256,12 @@ def webhook_send_task(
) )
signature = hmac_signature.hexdigest() signature = hmac_signature.hexdigest()
headers["X-Plane-Signature"] = signature headers["X-Plane-Signature"] = signature
except Exception as e:
log_exception(e)
logger.error(f"Failed to send webhook: {e}")
return
try:
# Send the webhook event # Send the webhook event
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30) response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)
@ -314,7 +278,7 @@ def webhook_send_task(
response_body=str(response.text), response_body=str(response.text),
retry_count=str(self.request.retries), retry_count=str(self.request.retries),
) )
logger.info(f"Webhook {webhook.id} sent successfully")
except requests.RequestException as e: except requests.RequestException as e:
# Log the failed webhook request # Log the failed webhook request
WebhookLog.objects.create( WebhookLog.objects.create(
@ -329,12 +293,13 @@ def webhook_send_task(
response_body=str(e), response_body=str(e),
retry_count=str(self.request.retries), retry_count=str(self.request.retries),
) )
logger.error(f"Webhook {webhook.id} failed with error: {e}")
# Retry logic # Retry logic
if self.request.retries >= self.max_retries: if self.request.retries >= self.max_retries:
Webhook.objects.filter(pk=webhook.id).update(is_active=False) Webhook.objects.filter(pk=webhook.id).update(is_active=False)
if webhook: if webhook:
# send email for the deactivation of the webhook # send email for the deactivation of the webhook
send_webhook_deactivation_email( send_webhook_deactivation_email.delay(
webhook_id=webhook.id, webhook_id=webhook.id,
receiver_id=webhook.created_by_id, receiver_id=webhook.created_by_id,
reason=str(e), reason=str(e),
@ -344,26 +309,50 @@ def webhook_send_task(
raise requests.RequestException() raise requests.RequestException()
except Exception as e: except Exception as e:
if settings.DEBUG:
print(e)
log_exception(e) log_exception(e)
return return
@shared_task @shared_task
def webhook_activity( def webhook_activity(
event, event: str,
verb, verb: str,
field, field: Optional[str],
old_value, old_value: Any,
new_value, new_value: Any,
actor_id, actor_id: str | uuid.UUID,
slug, slug: str,
current_site, current_site: str,
event_id, event_id: str | uuid.UUID,
old_identifier, old_identifier: Optional[str],
new_identifier, new_identifier: Optional[str],
): ) -> None:
"""
Process and send webhook notifications for various activities in the system.
This task filters relevant webhooks based on the event type and sends notifications
to all active webhooks for the workspace.
Args:
event (str): Type of event (project, issue, module, cycle, issue_comment)
verb (str): Action performed (created, updated, deleted)
field (Optional[str]): Name of the field that was changed
old_value (Any): Previous value of the field
new_value (Any): New value of the field
actor_id (str | uuid.UUID): ID of the user who performed the action
slug (str): Workspace slug
current_site (str): Current site URL
event_id (str | uuid.UUID): ID of the event object
old_identifier (Optional[str]): Previous identifier if any
new_identifier (Optional[str]): New identifier if any
Returns:
None
Note:
The function silently returns on ObjectDoesNotExist exceptions to handle
race conditions where objects might have been deleted.
"""
try: try:
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
@ -384,7 +373,7 @@ def webhook_activity(
for webhook in webhooks: for webhook in webhooks:
webhook_send_task.delay( webhook_send_task.delay(
webhook=webhook.id, webhook_id=webhook.id,
slug=slug, slug=slug,
event=event, event=event,
event_data=( event_data=(

View file

@ -0,0 +1,177 @@
# Python imports
import logging
# Third party imports
from celery import shared_task
import requests
from bs4 import BeautifulSoup
from urllib.parse import urlparse, urljoin
import base64
import ipaddress
from typing import Dict, Any
from typing import Optional
from plane.db.models import IssueLink
from plane.utils.exception_logger import log_exception
logger = logging.getLogger("plane.worker")
DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
"""
Crawls a URL to extract the title and favicon.
Args:
url (str): The URL to crawl
Returns:
str: JSON string containing title and base64-encoded favicon
"""
try:
# Prevent access to private IP ranges
parsed = urlparse(url)
try:
ip = ipaddress.ip_address(parsed.hostname)
if ip.is_private or ip.is_loopback or ip.is_reserved:
raise ValueError("Access to private/internal networks is not allowed")
except ValueError:
# Not an IP address, continue with domain validation
pass
# Set up headers to mimic a real browser
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa: E501
}
soup = None
title = None
try:
response = requests.get(url, headers=headers, timeout=1)
soup = BeautifulSoup(response.content, "html.parser")
title_tag = soup.find("title")
title = title_tag.get_text().strip() if title_tag else None
except requests.RequestException as e:
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
# Fetch and encode favicon
favicon_base64 = fetch_and_encode_favicon(headers, soup, url)
# Prepare result
result = {
"title": title,
"favicon": favicon_base64["favicon_base64"],
"url": url,
"favicon_url": favicon_base64["favicon_url"],
}
return result
except Exception as e:
log_exception(e)
return {
"error": f"Unexpected error: {str(e)}",
"title": None,
"favicon": None,
"url": url,
}
def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[str]:
"""
Find the favicon URL from HTML soup.
Args:
soup: BeautifulSoup object
base_url: Base URL for resolving relative paths
Returns:
str: Absolute URL to favicon or None
"""
if soup is not None:
# Look for various favicon link tags
favicon_selectors = [
'link[rel="icon"]',
'link[rel="shortcut icon"]',
'link[rel="apple-touch-icon"]',
'link[rel="apple-touch-icon-precomposed"]',
]
for selector in favicon_selectors:
favicon_tag = soup.select_one(selector)
if favicon_tag and favicon_tag.get("href"):
return urljoin(base_url, favicon_tag["href"])
# Fallback to /favicon.ico
parsed_url = urlparse(base_url)
fallback_url = f"{parsed_url.scheme}://{parsed_url.netloc}/favicon.ico"
# Check if fallback exists
try:
response = requests.head(fallback_url, timeout=2)
if response.status_code == 200:
return fallback_url
except requests.RequestException as e:
log_exception(e)
return None
return None
def fetch_and_encode_favicon(
headers: Dict[str, str], soup: Optional[BeautifulSoup], url: str
) -> Dict[str, Optional[str]]:
"""
Fetch favicon and encode it as base64.
Args:
favicon_url: URL to the favicon
headers: Request headers
Returns:
str: Base64 encoded favicon with data URI prefix or None
"""
try:
favicon_url = find_favicon_url(soup, url)
if favicon_url is None:
return {
"favicon_url": None,
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}
response = requests.get(favicon_url, headers=headers, timeout=1)
# Get content type
content_type = response.headers.get("content-type", "image/x-icon")
# Convert to base64
favicon_base64 = base64.b64encode(response.content).decode("utf-8")
# Return as data URI
return {
"favicon_url": favicon_url,
"favicon_base64": f"data:{content_type};base64,{favicon_base64}",
}
except Exception as e:
logger.warning(f"Failed to fetch favicon: {e}")
return {
"favicon_url": None,
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}
@shared_task
def crawl_work_item_link_title(id: str, url: str) -> None:
meta_data = crawl_work_item_link_title_and_favicon(url)
issue_link = IssueLink.objects.get(id=id)
issue_link.metadata = meta_data
issue_link.save()

View file

@ -78,7 +78,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter):
) )
msg.attach_alternative(html_content, "text/html") msg.attach_alternative(html_content, "text/html")
msg.send() msg.send()
logging.getLogger("plane").info("Email sent successfully") logging.getLogger("plane.worker").info("Email sent successfully")
return return
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist): except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist):
return return

View file

@ -0,0 +1,319 @@
# Python imports
import os
import json
import time
import uuid
from typing import Dict
import logging
# Django imports
from django.conf import settings
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import (
Workspace,
WorkspaceMember,
Project,
ProjectMember,
IssueUserProperty,
State,
Label,
Issue,
IssueLabel,
IssueSequence,
IssueActivity,
)
logger = logging.getLogger("plane.worker")
def read_seed_file(filename):
"""
Read a JSON file from the seed directory.
Args:
filename (str): Name of the JSON file to read
Returns:
dict: Contents of the JSON file
"""
file_path = os.path.join(settings.SEED_DIR, "data", filename)
try:
with open(file_path, "r") as file:
return json.load(file)
except FileNotFoundError:
logger.error(f"Seed file {filename} not found in {settings.SEED_DIR}/data")
return None
except json.JSONDecodeError:
logger.error(f"Error decoding JSON from {filename}")
return None
def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
"""Creates a project and associated members for a workspace.
Creates a new project using the workspace name and sets up all necessary
member associations and user properties.
Args:
workspace: The workspace to create the project in
Returns:
A mapping of seed project IDs to actual project IDs
"""
project_seeds = read_seed_file("projects.json")
project_identifier = "".join(ch for ch in workspace.name if ch.isalnum())[:5]
# Create members
workspace_members = WorkspaceMember.objects.filter(workspace=workspace).values(
"member_id", "role"
)
projects_map: Dict[int, uuid.UUID] = {}
if not project_seeds:
logger.warning(
"Task: workspace_seed_task -> No project seeds found. Skipping project creation."
)
return projects_map
for project_seed in project_seeds:
project_id = project_seed.pop("id")
# Remove the name from seed data since we want to use workspace name
project_seed.pop("name", None)
project_seed.pop("identifier", None)
project = Project.objects.create(
**project_seed,
workspace=workspace,
name=workspace.name, # Use workspace name
identifier=project_identifier,
created_by_id=workspace.created_by_id,
)
# Create project members
ProjectMember.objects.bulk_create(
[
ProjectMember(
project=project,
member_id=workspace_member["member_id"],
role=workspace_member["role"],
workspace_id=workspace.id,
created_by_id=workspace.created_by_id,
)
for workspace_member in workspace_members
]
)
# Create issue user properties
IssueUserProperty.objects.bulk_create(
[
IssueUserProperty(
project=project,
user_id=workspace_member["member_id"],
workspace_id=workspace.id,
display_filters={
"group_by": None,
"order_by": "sort_order",
"type": None,
"sub_issue": True,
"show_empty_groups": True,
"layout": "list",
"calendar_date_range": "",
},
created_by_id=workspace.created_by_id,
)
for workspace_member in workspace_members
]
)
# update map
projects_map[project_id] = project.id
logger.info(f"Task: workspace_seed_task -> Project {project_id} created")
return projects_map
def create_project_states(
workspace: Workspace, project_map: Dict[int, uuid.UUID]
) -> Dict[int, uuid.UUID]:
"""Creates states for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
Returns:
A mapping of seed state IDs to actual state IDs
"""
state_seeds = read_seed_file("states.json")
state_map: Dict[int, uuid.UUID] = {}
if not state_seeds:
return state_map
for state_seed in state_seeds:
state_id = state_seed.pop("id")
project_id = state_seed.pop("project_id")
state = State.objects.create(
**state_seed,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=workspace.created_by_id,
)
state_map[state_id] = state.id
logger.info(f"Task: workspace_seed_task -> State {state_id} created")
return state_map
def create_project_labels(
workspace: Workspace, project_map: Dict[int, uuid.UUID]
) -> Dict[int, uuid.UUID]:
"""Creates labels for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
Returns:
A mapping of seed label IDs to actual label IDs
"""
label_seeds = read_seed_file("labels.json")
label_map: Dict[int, uuid.UUID] = {}
if not label_seeds:
return label_map
for label_seed in label_seeds:
label_id = label_seed.pop("id")
project_id = label_seed.pop("project_id")
label = Label.objects.create(
**label_seed,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=workspace.created_by_id,
)
label_map[label_id] = label.id
logger.info(f"Task: workspace_seed_task -> Label {label_id} created")
return label_map
def create_project_issues(
workspace: Workspace,
project_map: Dict[int, uuid.UUID],
states_map: Dict[int, uuid.UUID],
labels_map: Dict[int, uuid.UUID],
) -> None:
"""Creates issues and their associated records for each project.
Creates issues along with their sequences, activities, and label associations.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
states_map: Mapping of seed state IDs to actual state IDs
labels_map: Mapping of seed label IDs to actual label IDs
"""
issue_seeds = read_seed_file("issues.json")
if not issue_seeds:
return
for issue_seed in issue_seeds:
required_fields = ["id", "labels", "project_id", "state_id"]
# get the values
for field in required_fields:
if field not in issue_seed:
logger.error(
f"Task: workspace_seed_task -> Required field '{field}' missing in issue seed"
)
continue
# get the values
issue_id = issue_seed.pop("id")
labels = issue_seed.pop("labels")
project_id = issue_seed.pop("project_id")
state_id = issue_seed.pop("state_id")
issue = Issue.objects.create(
**issue_seed,
state_id=states_map[state_id],
project_id=project_map[project_id],
workspace=workspace,
created_by_id=workspace.created_by_id,
)
IssueSequence.objects.create(
issue=issue,
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=workspace.created_by_id,
)
IssueActivity.objects.create(
issue=issue,
project_id=project_map[project_id],
workspace_id=workspace.id,
comment="created the issue",
verb="created",
actor_id=workspace.created_by_id,
epoch=time.time(),
)
for label_id in labels:
IssueLabel.objects.create(
issue=issue,
label_id=labels_map[label_id],
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=workspace.created_by_id,
)
logger.info(f"Task: workspace_seed_task -> Issue {issue_id} created")
return
@shared_task
def workspace_seed(workspace_id: uuid.UUID) -> None:
"""Seeds a new workspace with initial project data.
Creates a complete workspace setup including:
- Projects and project members
- Project states
- Project labels
- Issues and their associations
Args:
workspace_id: ID of the workspace to seed
"""
try:
logger.info(f"Task: workspace_seed_task -> Seeding workspace {workspace_id}")
# Get the workspace
workspace = Workspace.objects.get(id=workspace_id)
# Create a project with the same name as workspace
project_map = create_project_and_member(workspace)
# Create project states
state_map = create_project_states(workspace, project_map)
# Create project labels
label_map = create_project_labels(workspace, project_map)
# create project issues
create_project_issues(workspace, project_map, state_map, label_map)
logger.info(
f"Task: workspace_seed_task -> Workspace {workspace_id} seeded successfully"
)
return
except Exception as e:
logger.error(
f"Task: workspace_seed_task -> Failed to seed workspace {workspace_id}: {str(e)}"
)
raise e

View file

@ -5,7 +5,9 @@ from plane.db.models import Workspace
class Command(BaseCommand): class Command(BaseCommand):
help = "Updates the slug of a soft-deleted workspace by appending the epoch timestamp" help = (
"Updates the slug of a soft-deleted workspace by appending the epoch timestamp"
)
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument( parser.add_argument(

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