release: v0.27.0 #7296
This commit is contained in:
commit
5a3f709e72
1615 changed files with 27874 additions and 19715 deletions
|
|
@ -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
|
||||||
|
|
@ -14,4 +15,4 @@ build/
|
||||||
out/
|
out/
|
||||||
**/out/
|
**/out/
|
||||||
dist/
|
dist/
|
||||||
**/dist/
|
**/dist/
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
1
.yarnrc.yml
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
@ -35,11 +35,12 @@ 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
|
||||||
- **Memory**: Minimum **12 GB RAM** recommended
|
- **Memory**: Minimum **12 GB RAM** recommended
|
||||||
> ⚠️ Running the project on a system with only 8 GB RAM may lead to setup failures or memory crashes (especially during Docker container build/start or dependency install). Use cloud environments like GitHub Codespaces or upgrade local RAM if possible.
|
> ⚠️ Running the project on a system with only 8 GB RAM may lead to setup failures or memory crashes (especially during Docker container build/start or dependency install). Use cloud environments like GitHub Codespaces or upgrade local RAM if possible.
|
||||||
|
|
||||||
### Setup the project
|
### Setup the project
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
That’s it! You’re all set to begin coding. Remember to refresh your browser if changes don’t 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.
|
||||||
|
|
@ -93,7 +105,7 @@ To ensure consistency throughout the source code, please keep these rules in min
|
||||||
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
|
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
|
||||||
|
|
||||||
## Contributing to language support
|
## Contributing to language support
|
||||||
This guide is designed to help contributors understand how to add or update translations in the application.
|
This guide is designed to help contributors understand how to add or update translations in the application.
|
||||||
|
|
||||||
### Understanding translation structure
|
### Understanding translation structure
|
||||||
|
|
||||||
|
|
@ -108,7 +120,7 @@ packages/i18n/src/locales/
|
||||||
├── fr/
|
├── fr/
|
||||||
│ └── translations.json
|
│ └── translations.json
|
||||||
└── [language]/
|
└── [language]/
|
||||||
└── translations.json
|
└── translations.json
|
||||||
```
|
```
|
||||||
#### Nested structure
|
#### Nested structure
|
||||||
To keep translations organized, we use a nested structure for keys. This makes it easier to manage and locate specific translations. For example:
|
To keep translations organized, we use a nested structure for keys. This makes it easier to manage and locate specific translations. For example:
|
||||||
|
|
@ -128,14 +140,14 @@ To keep translations organized, we use a nested structure for keys. This makes i
|
||||||
We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/) to handle dynamic content, such as variables and pluralization. Here's how to format your translations:
|
We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/) to handle dynamic content, such as variables and pluralization. Here's how to format your translations:
|
||||||
|
|
||||||
#### Examples
|
#### Examples
|
||||||
- **Simple variables**
|
- **Simple variables**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"greeting": "Hello, {name}!"
|
"greeting": "Hello, {name}!"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Pluralization**
|
- **Pluralization**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"items": "{count, plural, one {Work item} other {Work items}}"
|
"items": "{count, plural, one {Work item} other {Work items}}"
|
||||||
|
|
@ -160,15 +172,15 @@ We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/)
|
||||||
### Adding new languages
|
### Adding new languages
|
||||||
Adding a new language involves several steps to ensure it integrates seamlessly with the project. Follow these instructions carefully:
|
Adding a new language involves several steps to ensure it integrates seamlessly with the project. Follow these instructions carefully:
|
||||||
|
|
||||||
1. **Update type definitions**
|
1. **Update type definitions**
|
||||||
Add the new language to the TLanguage type in the language definitions file:
|
Add the new language to the TLanguage type in the language definitions file:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// types/language.ts
|
// types/language.ts
|
||||||
export type TLanguage = "en" | "fr" | "your-lang";
|
export type TLanguage = "en" | "fr" | "your-lang";
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Add language configuration**
|
2. **Add language configuration**
|
||||||
Include the new language in the list of supported languages:
|
Include the new language in the list of supported languages:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -179,14 +191,14 @@ Include the new language in the list of supported languages:
|
||||||
];
|
];
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Create translation files**
|
3. **Create translation files**
|
||||||
1. Create a new folder for your language under locales (e.g., `locales/your-lang/`).
|
1. Create a new folder for your language under locales (e.g., `locales/your-lang/`).
|
||||||
|
|
||||||
2. Add a `translations.json` file inside the folder.
|
2. Add a `translations.json` file inside the folder.
|
||||||
|
|
||||||
3. Copy the structure from an existing translation file and translate all keys.
|
3. Copy the structure from an existing translation file and translate all keys.
|
||||||
|
|
||||||
4. **Update import logic**
|
4. **Update import logic**
|
||||||
Modify the language import logic to include your new language:
|
Modify the language import logic to include your new language:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
|
||||||
55
README.md
55
README.md
|
|
@ -47,10 +47,10 @@ Meet [Plane](https://plane.so/), an open-source project management tool to track
|
||||||
|
|
||||||
Getting started with Plane is simple. Choose the setup that works best for you:
|
Getting started with Plane is simple. Choose the setup that works best for you:
|
||||||
|
|
||||||
- **Plane Cloud**
|
- **Plane Cloud**
|
||||||
Sign up for a free account on [Plane Cloud](https://app.plane.so)—it's the fastest way to get up and running without worrying about infrastructure.
|
Sign up for a free account on [Plane Cloud](https://app.plane.so)—it's the fastest way to get up and running without worrying about infrastructure.
|
||||||
|
|
||||||
- **Self-host Plane**
|
- **Self-host Plane**
|
||||||
Prefer full control over your data and infrastructure? Install and run Plane on your own servers. Follow our detailed [deployment guides](https://developers.plane.so/self-hosting/overview) to get started.
|
Prefer full control over your data and infrastructure? Install and run Plane on your own servers. Follow our detailed [deployment guides](https://developers.plane.so/self-hosting/overview) to get started.
|
||||||
|
|
||||||
| Installation methods | Docs link |
|
| Installation methods | Docs link |
|
||||||
|
|
@ -62,22 +62,22 @@ Prefer full control over your data and infrastructure? Install and run Plane on
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
- **Issues**
|
- **Issues**
|
||||||
Efficiently create and manage tasks with a robust rich text editor that supports file uploads. Enhance organization and tracking by adding sub-properties and referencing related issues.
|
Efficiently create and manage tasks with a robust rich text editor that supports file uploads. Enhance organization and tracking by adding sub-properties and referencing related issues.
|
||||||
|
|
||||||
- **Cycles**
|
- **Cycles**
|
||||||
Maintain your team’s momentum with Cycles. Track progress effortlessly using burn-down charts and other insightful tools.
|
Maintain your team’s momentum with Cycles. Track progress effortlessly using burn-down charts and other insightful tools.
|
||||||
|
|
||||||
- **Modules**
|
- **Modules**
|
||||||
Simplify complex projects by dividing them into smaller, manageable modules.
|
Simplify complex projects by dividing them into smaller, manageable modules.
|
||||||
|
|
||||||
- **Views**
|
- **Views**
|
||||||
Customize your workflow by creating filters to display only the most relevant issues. Save and share these views with ease.
|
Customize your workflow by creating filters to display only the most relevant issues. Save and share these views with ease.
|
||||||
|
|
||||||
- **Pages**
|
- **Pages**
|
||||||
Capture and organize ideas using Plane Pages, complete with AI capabilities and a rich text editor. Format text, insert images, add hyperlinks, or convert your notes into actionable items.
|
Capture and organize ideas using Plane Pages, complete with AI capabilities and a rich text editor. Format text, insert images, add hyperlinks, or convert your notes into actionable items.
|
||||||
|
|
||||||
- **Analytics**
|
- **Analytics**
|
||||||
Access real-time insights across all your Plane data. Visualize trends, remove blockers, and keep your projects moving forward.
|
Access real-time insights across all your Plane data. Visualize trends, remove blockers, and keep your projects moving forward.
|
||||||
|
|
||||||
- **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution.
|
- **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution.
|
||||||
|
|
@ -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
|
|
||||||
```
|
|
||||||
|
|
||||||
That’s it! You’re all set to begin coding. Remember to refresh your browser if changes don’t auto-reload. Happy contributing! 🎉
|
|
||||||
|
|
||||||
## ⚙️ Built with
|
## ⚙️ Built with
|
||||||
[](https://nextjs.org/)
|
[](https://nextjs.org/)
|
||||||
|
|
@ -194,7 +163,7 @@ Feel free to ask questions, report bugs, participate in discussions, share ideas
|
||||||
|
|
||||||
If you discover a security vulnerability in Plane, please report it responsibly instead of opening a public issue. We take all legitimate reports seriously and will investigate them promptly. See [Security policy](https://github.com/makeplane/plane/blob/master/SECURITY.md) for more info.
|
If you discover a security vulnerability in Plane, please report it responsibly instead of opening a public issue. We take all legitimate reports seriously and will investigate them promptly. See [Security policy](https://github.com/makeplane/plane/blob/master/SECURITY.md) for more info.
|
||||||
|
|
||||||
To disclose any security issues, please email us at security@plane.so.
|
To disclose any security issues, please email us at security@plane.so.
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
|
@ -219,4 +188,4 @@ Please read [CONTRIBUTING.md](https://github.com/makeplane/plane/blob/master/CON
|
||||||
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
This project is licensed under the [GNU Affero General Public License v3.0](https://github.com/makeplane/plane/blob/master/LICENSE.txt).
|
This project is licensed under the [GNU Affero General Public License v3.0](https://github.com/makeplane/plane/blob/master/LICENSE.txt).
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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} />}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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 }));
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export * from "ce/components/authentication/authentication-modes";
|
export * from "ce/components/authentication/authentication-modes";
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
@ -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
25
apiserver/.coveragerc
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,4 @@ from .state import StateLiteSerializer, StateSerializer
|
||||||
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
|
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
|
||||||
from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
|
from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
|
||||||
from .intake import IntakeIssueSerializer
|
from .intake import IntakeIssueSerializer
|
||||||
from .estimate import EstimatePointSerializer
|
from .estimate import EstimatePointSerializer
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
member_id=default_assignee_id,
|
default_assignee_id is not None
|
||||||
project_id=project_id,
|
and ProjectMember.objects.filter(
|
||||||
role__gte=15,
|
member_id=default_assignee_id,
|
||||||
is_active=True
|
project_id=project_id,
|
||||||
).exists():
|
role__gte=15,
|
||||||
|
is_active=True,
|
||||||
|
).exists()
|
||||||
|
):
|
||||||
IssueAssignee.objects.create(
|
IssueAssignee.objects.create(
|
||||||
assignee_id=default_assignee_id,
|
assignee_id=default_assignee_id,
|
||||||
issue=issue,
|
issue=issue,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -148,10 +148,13 @@ class ProjectMemberAdminSerializer(BaseSerializer):
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,10 +183,8 @@ 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():
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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/",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
366
apiserver/plane/app/views/analytic/advance.py
Normal file
366
apiserver/plane/app/views/analytic/advance.py
Normal 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)
|
||||||
421
apiserver/plane/app/views/analytic/project_analytics.py
Normal file
421
apiserver/plane/app/views/analytic/project_analytics.py
Normal 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)
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,53 +1176,60 @@ 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(
|
||||||
|
issue_cycle__cycle_id=cycle_id,
|
||||||
|
issue_cycle__deleted_at__isnull=True,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
state__group="backlog",
|
||||||
|
).count()
|
||||||
|
|
||||||
backlog_issues = Issue.issue_objects.filter(
|
unstarted_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,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
state__group="backlog",
|
state__group="unstarted",
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
unstarted_issues = Issue.issue_objects.filter(
|
started_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,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
state__group="unstarted",
|
state__group="started",
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
started_issues = Issue.issue_objects.filter(
|
cancelled_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,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
state__group="started",
|
state__group="cancelled",
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
cancelled_issues = Issue.issue_objects.filter(
|
completed_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,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
state__group="cancelled",
|
state__group="completed",
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
completed_issues = Issue.issue_objects.filter(
|
total_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,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
state__group="completed",
|
).count()
|
||||||
).count()
|
|
||||||
|
|
||||||
total_issues = Issue.issue_objects.filter(
|
|
||||||
issue_cycle__cycle_id=cycle_id,
|
|
||||||
issue_cycle__deleted_at__isnull=True,
|
|
||||||
workspace__slug=slug,
|
|
||||||
project_id=project_id,
|
|
||||||
).count()
|
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
69
apiserver/plane/app/views/external/base.py
vendored
69
apiserver/plane/app/views/external/base.py
vendored
|
|
@ -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,40 +51,45 @@ 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",
|
{
|
||||||
"default": os.environ.get("LLM_API_KEY", None),
|
"key": "LLM_API_KEY",
|
||||||
},
|
"default": os.environ.get("LLM_API_KEY", None),
|
||||||
{
|
},
|
||||||
"key": "LLM_PROVIDER",
|
{
|
||||||
"default": os.environ.get("LLM_PROVIDER", "openai"),
|
"key": "LLM_PROVIDER",
|
||||||
},
|
"default": os.environ.get("LLM_PROVIDER", "openai"),
|
||||||
{
|
},
|
||||||
"key": "LLM_MODEL",
|
{
|
||||||
"default": os.environ.get("LLM_MODEL", None),
|
"key": "LLM_MODEL",
|
||||||
},
|
"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(
|
||||||
f"Model {model} not supported by {provider.name}. "
|
ValueError(
|
||||||
f"Supported models: {', '.join(provider.models)}"
|
f"Model {model} not supported by {provider.name}. "
|
||||||
))
|
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."},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 = """
|
||||||
|
|
@ -198,7 +199,7 @@ class PageViewSet(BaseViewSet):
|
||||||
project = Project.objects.get(pk=project_id)
|
project = Project.objects.get(pk=project_id)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if the role is guest and guest_view_all_features is false and owned by is not
|
if the role is guest and guest_view_all_features is false and owned by is not
|
||||||
the requesting user then dont show the page
|
the requesting user then dont show the page
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,126 +233,36 @@ 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
|
# List Paginate
|
||||||
group_by = request.GET.get("group_by", False)
|
return self.paginate(
|
||||||
sub_group_by = request.GET.get("sub_group_by", False)
|
order_by=order_by_param,
|
||||||
|
request=request,
|
||||||
# issue queryset
|
queryset=issue_queryset,
|
||||||
issue_queryset = issue_queryset_grouper(
|
on_results=lambda issues: ViewIssueListSerializer(issues, many=True).data,
|
||||||
queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by
|
total_count_queryset=total_issue_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
|
||||||
return self.paginate(
|
|
||||||
order_by=order_by_param,
|
|
||||||
request=request,
|
|
||||||
queryset=issue_queryset,
|
|
||||||
on_results=lambda issues: issue_on_results(
|
|
||||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class IssueViewViewSet(BaseViewSet):
|
class IssueViewViewSet(BaseViewSet):
|
||||||
serializer_class = IssueViewSerializer
|
serializer_class = IssueViewSerializer
|
||||||
|
|
|
||||||
|
|
@ -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 = (
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 = (
|
||||||
user=request.user, workspace_id=workspace.id
|
WorkspaceUserPreference.objects.filter(
|
||||||
).order_by("sort_order").values("key", "is_pinned", "sort_order")
|
user=request.user, workspace_id=workspace.id
|
||||||
|
)
|
||||||
|
.order_by("sort_order")
|
||||||
|
.values("key", "is_pinned", "sort_order")
|
||||||
|
)
|
||||||
|
|
||||||
user_preferences = {}
|
user_preferences = {}
|
||||||
|
|
||||||
|
|
@ -58,7 +61,7 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
||||||
user_preferences[(str(preference["key"]))] = {
|
user_preferences[(str(preference["key"]))] = {
|
||||||
"is_pinned": preference["is_pinned"],
|
"is_pinned": preference["is_pinned"],
|
||||||
"sort_order": preference["sort_order"],
|
"sort_order": preference["sort_order"],
|
||||||
}
|
}
|
||||||
return Response(
|
return Response(
|
||||||
user_preferences,
|
user_preferences,
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -25,23 +25,24 @@ 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",
|
{
|
||||||
"default": os.environ.get("GITHUB_CLIENT_ID"),
|
"key": "GITHUB_CLIENT_ID",
|
||||||
},
|
"default": os.environ.get("GITHUB_CLIENT_ID"),
|
||||||
{
|
},
|
||||||
"key": "GITHUB_CLIENT_SECRET",
|
{
|
||||||
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
|
"key": "GITHUB_CLIENT_SECRET",
|
||||||
},
|
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
|
||||||
{
|
},
|
||||||
"key": "GITHUB_ORGANIZATION_ID",
|
{
|
||||||
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
|
"key": "GITHUB_ORGANIZATION_ID",
|
||||||
},
|
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
|
||||||
]
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET):
|
if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET):
|
||||||
|
|
@ -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(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -53,12 +53,14 @@ 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)
|
||||||
|
|
||||||
if not new_password:
|
if not new_password:
|
||||||
exc = AuthenticationException(
|
exc = AuthenticationException(
|
||||||
error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"],
|
error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"],
|
||||||
error_message="MISSING_PASSWORD",
|
error_message="MISSING_PASSWORD",
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,68 +346,169 @@ 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,
|
project__project_projectmember__member=exporter_instance.initiated_by_id,
|
||||||
project__project_projectmember__member=exporter_instance.initiated_by_id,
|
project__project_projectmember__is_active=True,
|
||||||
project__project_projectmember__is_active=True,
|
project__archived_at__isnull=True,
|
||||||
project__archived_at__isnull=True,
|
)
|
||||||
)
|
.select_related(
|
||||||
.select_related("project", "workspace", "state", "parent", "created_by")
|
"project",
|
||||||
.prefetch_related(
|
"workspace",
|
||||||
"assignees", "labels", "issue_cycle__cycle", "issue_module__module"
|
"state",
|
||||||
)
|
"parent",
|
||||||
.values(
|
"created_by",
|
||||||
"id",
|
"estimate_point",
|
||||||
"project__identifier",
|
)
|
||||||
"project__name",
|
.prefetch_related(
|
||||||
"project__id",
|
"labels",
|
||||||
"sequence_id",
|
"issue_cycle__cycle",
|
||||||
"name",
|
"issue_module__module",
|
||||||
"description_stripped",
|
"issue_comments",
|
||||||
"priority",
|
"assignees",
|
||||||
"start_date",
|
Prefetch(
|
||||||
"target_date",
|
"assignees",
|
||||||
"state__name",
|
queryset=User.objects.only("first_name", "last_name").distinct(),
|
||||||
"created_at",
|
to_attr="assignee_details",
|
||||||
"updated_at",
|
),
|
||||||
"completed_at",
|
Prefetch(
|
||||||
"archived_at",
|
"labels",
|
||||||
"issue_cycle__cycle__name",
|
queryset=Label.objects.only("name").distinct(),
|
||||||
"issue_cycle__cycle__start_date",
|
to_attr="label_details",
|
||||||
"issue_cycle__cycle__end_date",
|
),
|
||||||
"issue_module__module__name",
|
"issue_subscribers",
|
||||||
"issue_module__module__start_date",
|
"issue_link",
|
||||||
"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()
|
|
||||||
)
|
)
|
||||||
# CSV header
|
|
||||||
|
# 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
|
||||||
header = [
|
header = [
|
||||||
"ID",
|
"ID",
|
||||||
"Project",
|
"Project",
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,150 +71,89 @@ 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 many:
|
if model is None:
|
||||||
queryset = model.objects.filter(pk__in=event_id)
|
raise ValueError(f"Model not found for event: {event}")
|
||||||
else:
|
|
||||||
queryset = model.objects.get(pk=event_id)
|
|
||||||
serializer = SERIALIZER_MAPPER.get(event)
|
|
||||||
return serializer(queryset, many=many).data
|
|
||||||
|
|
||||||
|
|
||||||
@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:
|
try:
|
||||||
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
|
if many:
|
||||||
|
queryset = model.objects.filter(pk__in=event_id)
|
||||||
|
else:
|
||||||
|
queryset = model.objects.get(pk=event_id)
|
||||||
|
|
||||||
headers = {
|
serializer = SERIALIZER_MAPPER.get(event)
|
||||||
"Content-Type": "application/json",
|
if serializer is None:
|
||||||
"User-Agent": "Autopilot",
|
raise ValueError(f"Serializer not found for event: {event}")
|
||||||
"X-Plane-Delivery": str(uuid.uuid4()),
|
|
||||||
"X-Plane-Event": event,
|
|
||||||
}
|
|
||||||
|
|
||||||
# # Your secret key
|
return serializer(queryset, many=many).data
|
||||||
event_data = (
|
except ObjectDoesNotExist:
|
||||||
json.loads(json.dumps(event_data, cls=DjangoJSONEncoder))
|
raise ObjectDoesNotExist(f"No {event} found with id: {event_id}")
|
||||||
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:
|
||||||
EMAIL_HOST,
|
"""
|
||||||
EMAIL_HOST_USER,
|
Send an email notification when a webhook is deactivated.
|
||||||
EMAIL_HOST_PASSWORD,
|
|
||||||
EMAIL_PORT,
|
|
||||||
EMAIL_USE_TLS,
|
|
||||||
EMAIL_USE_SSL,
|
|
||||||
EMAIL_FROM,
|
|
||||||
) = get_email_configuration()
|
|
||||||
|
|
||||||
receiver = User.objects.get(pk=receiver_id)
|
|
||||||
webhook = Webhook.objects.get(pk=webhook_id)
|
|
||||||
subject = "Webhook Deactivated"
|
|
||||||
message = f"Webhook {webhook.url} has been deactivated due to failed requests."
|
|
||||||
|
|
||||||
# Send the mail
|
|
||||||
context = {
|
|
||||||
"email": receiver.email,
|
|
||||||
"message": message,
|
|
||||||
"webhook_url": f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}",
|
|
||||||
}
|
|
||||||
html_content = render_to_string(
|
|
||||||
"emails/notifications/webhook-deactivate.html", context
|
|
||||||
)
|
|
||||||
text_content = strip_tags(html_content)
|
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
|
(
|
||||||
|
EMAIL_HOST,
|
||||||
|
EMAIL_HOST_USER,
|
||||||
|
EMAIL_HOST_PASSWORD,
|
||||||
|
EMAIL_PORT,
|
||||||
|
EMAIL_USE_TLS,
|
||||||
|
EMAIL_USE_SSL,
|
||||||
|
EMAIL_FROM,
|
||||||
|
) = get_email_configuration()
|
||||||
|
|
||||||
|
receiver = User.objects.get(pk=receiver_id)
|
||||||
|
webhook = Webhook.objects.get(pk=webhook_id)
|
||||||
|
|
||||||
|
# Get the webhook payload
|
||||||
|
subject = "Webhook Deactivated"
|
||||||
|
message = f"Webhook {webhook.url} has been deactivated due to failed requests."
|
||||||
|
|
||||||
|
# Send the mail
|
||||||
|
context = {
|
||||||
|
"email": receiver.email,
|
||||||
|
"message": message,
|
||||||
|
"webhook_url": f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}",
|
||||||
|
}
|
||||||
|
html_content = render_to_string(
|
||||||
|
"emails/notifications/webhook-deactivate.html", context
|
||||||
|
)
|
||||||
|
text_content = strip_tags(html_content)
|
||||||
|
|
||||||
|
# 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=(
|
||||||
|
|
|
||||||
177
apiserver/plane/bgtasks/work_item_link_task.py
Normal file
177
apiserver/plane/bgtasks/work_item_link_task.py
Normal 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()
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue