Compare commits

..

1 Commits

Author SHA1 Message Date
Aaryan Khandelwal 1c376aaf0a refactor: sub-work items components, hooks and types 2025-04-15 12:24:21 +05:30
1639 changed files with 18494 additions and 30492 deletions
+1 -2
View File
@@ -2,7 +2,6 @@
*.pyc
.env
venv
.venv
node_modules/
**/node_modules/
npm-debug.log
@@ -15,4 +14,4 @@ build/
out/
**/out/
dist/
**/dist/
**/dist/
+3 -42
View File
@@ -117,44 +117,6 @@ jobs:
name: Checkout Files
uses: actions/checkout@v4
- name: Get changed files
id: changed_files
uses: tj-actions/changed-files@v42
with:
files_yaml: |
apiserver:
- apiserver/**
proxy:
- caddy/**
admin:
- admin/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
space:
- space/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
web:
- web/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
live:
- live/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
branch_build_push_admin:
name: Build-Push Admin Docker Image
runs-on: ubuntu-22.04
@@ -280,8 +242,8 @@ jobs:
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }}
build-context: ./caddy
dockerfile-path: ./caddy/Dockerfile
build-context: ./nginx
dockerfile-path: ./nginx/Dockerfile
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
@@ -311,7 +273,7 @@ jobs:
run: |
cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh
sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deploy/selfhost/docker-compose.yml
# sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deploy/selfhost/variables.env
sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deploy/selfhost/variables.env
- name: Create Release
id: create_release
@@ -328,6 +290,5 @@ jobs:
${{ github.workspace }}/deploy/selfhost/setup.sh
${{ github.workspace }}/deploy/selfhost/swarm.sh
${{ github.workspace }}/deploy/selfhost/restore.sh
${{ github.workspace }}/deploy/selfhost/restore-airgapped.sh
${{ github.workspace }}/deploy/selfhost/docker-compose.yml
${{ github.workspace }}/deploy/selfhost/variables.env
-3
View File
@@ -1,6 +1,5 @@
node_modules
.next
.yarn
### NextJS ###
# Dependencies
@@ -53,8 +52,6 @@ mediafiles
.env
.DS_Store
logs/
htmlcov/
.coverage
node_modules/
assets/dist/
-1
View File
@@ -1 +0,0 @@
nodeLinker: node-modules
+10 -40
View File
@@ -15,33 +15,14 @@ Without said minimal reproduction, we won't be able to investigate all [issues](
You can open a new issue with this [issue form](https://github.com/makeplane/plane/issues/new).
### Naming conventions for issues
When opening a new issue, please use a clear and concise title that follows this format:
- For bugs: `🐛 Bug: [short description]`
- For features: `🚀 Feature: [short description]`
- For improvements: `🛠️ Improvement: [short description]`
- For documentation: `📘 Docs: [short description]`
**Examples:**
- `🐛 Bug: API token expiry time not saving correctly`
- `📘 Docs: Clarify RAM requirement for local setup`
- `🚀 Feature: Allow custom time selection for token expiration`
This helps us triage and manage issues more efficiently.
## Projects setup and Architecture
### Requirements
- Docker Engine installed and running
- Node.js version 20+ [LTS version](https://nodejs.org/en/about/previous-releases)
- Node.js version v16.18.0
- Python version 3.8+
- Postgres version v14
- Redis version v6.2.7
- **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.
### Setup the project
@@ -69,17 +50,6 @@ chmod +x setup.sh
docker compose -f docker-compose-local.yml up
```
4. Start web apps:
```bash
yarn dev
```
5. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
6. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step
Thats it! Youre all set to begin coding. Remember to refresh your browser if changes dont auto-reload. Happy contributing! 🎉
## Missing a Feature?
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.
@@ -105,7 +75,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.
## 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
@@ -120,7 +90,7 @@ packages/i18n/src/locales/
├── fr/
│ └── translations.json
└── [language]/
└── translations.json
└── translations.json
```
#### 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:
@@ -140,14 +110,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:
#### Examples
- **Simple variables**
- **Simple variables**
```json
{
"greeting": "Hello, {name}!"
}
```
- **Pluralization**
- **Pluralization**
```json
{
"items": "{count, plural, one {Work item} other {Work items}}"
@@ -172,15 +142,15 @@ We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/)
### Adding new languages
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:
```typescript
// types/language.ts
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:
```typescript
@@ -191,14 +161,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/`).
2. Add a `translations.json` file inside the folder.
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:
```typescript
+43 -12
View File
@@ -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:
- **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.
- **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.
| Installation methods | Docs link |
@@ -62,22 +62,22 @@ Prefer full control over your data and infrastructure? Install and run Plane on
## 🌟 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.
- **Cycles**
- **Cycles**
Maintain your teams momentum with Cycles. Track progress effortlessly using burn-down charts and other insightful tools.
- **Modules**
Simplify complex projects by dividing them into smaller, manageable modules.
- **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.
- **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.
- **Analytics**
- **Analytics**
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.
@@ -85,7 +85,38 @@ Access real-time insights across all your Plane data. Visualize trends, remove b
## 🛠️ Local development
See [CONTRIBUTING](./CONTRIBUTING.md)
### Pre-requisites
- Ensure Docker Engine is installed and running.
### Development setup
Setting up your local environment is simple and straightforward. Follow these steps to get started:
1. Clone the repository:
```
git clone https://github.com/makeplane/plane.git
```
2. Navigate to the project folder:
```
cd plane
```
3. Create a new branch for your feature or fix:
```
git checkout -b <feature-branch-name>
```
4. Run the setup script in the terminal:
```
./setup.sh
```
5. Open the project in an IDE such as VS Code.
6. Review the `.env` files in the relevant folders. Refer to [Environment Setup](./ENV_SETUP.md) for details on the environment variables used.
7. Start the services using Docker:
```
docker compose -f docker-compose-local.yml up -d
```
Thats it! Youre all set to begin coding. Remember to refresh your browser if changes dont auto-reload. Happy contributing! 🎉
## ⚙️ Built with
[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/)
@@ -163,7 +194,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.
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
@@ -188,4 +219,4 @@ Please read [CONTRIBUTING.md](https://github.com/makeplane/plane/blob/master/CON
## 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).
+2 -11
View File
@@ -1,12 +1,3 @@
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_API_BASE_URL=""
NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
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"
NEXT_PUBLIC_WEB_BASE_URL=""
+8 -8
View File
@@ -26,16 +26,16 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
formState: { errors, isSubmitting },
} = useForm<AIFormValues>({
defaultValues: {
LLM_API_KEY: config["LLM_API_KEY"],
LLM_MODEL: config["LLM_MODEL"],
OPENAI_API_KEY: config["OPENAI_API_KEY"],
GPT_ENGINE: config["GPT_ENGINE"],
},
});
const aiFormFields: TControllerInputFormField[] = [
{
key: "LLM_MODEL",
key: "GPT_ENGINE",
type: "text",
label: "LLM Model",
label: "GPT_ENGINE",
description: (
<>
Choose an OpenAI engine.{" "}
@@ -49,12 +49,12 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
</a>
</>
),
placeholder: "gpt-4o-mini",
error: Boolean(errors.LLM_MODEL),
placeholder: "gpt-3.5-turbo",
error: Boolean(errors.GPT_ENGINE),
required: false,
},
{
key: "LLM_API_KEY",
key: "OPENAI_API_KEY",
type: "password",
label: "API key",
description: (
@@ -71,7 +71,7 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
</>
),
placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd",
error: Boolean(errors.LLM_API_KEY),
error: Boolean(errors.OPENAI_API_KEY),
required: false,
},
];
+5 -1
View File
@@ -98,7 +98,11 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
key: "GITHUB_ORGANIZATION_ID",
type: "text",
label: "Organization ID",
description: <>The organization github ID.</>,
description: (
<>
The organization github ID.
</>
),
placeholder: "123456789",
error: Boolean(errors.GITHUB_ORGANIZATION_ID),
required: false,
@@ -1,4 +1,5 @@
@import "@plane/propel/styles/fonts";
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@48,400,0,0&display=swap");
@tailwind base;
@tailwind components;
@@ -59,31 +60,23 @@
--color-border-300: 212, 212, 212; /* strong border- 1 */
--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);
--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);
--color-shadow-sm:
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-sm: 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),
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);
--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);
--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);
--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);
--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);
--color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), 0px 12px 32px -16px rgba(0, 0, 0, 0.05);
+4 -2
View File
@@ -3,16 +3,18 @@
import { ReactNode } from "react";
import { ThemeProvider, useTheme } from "next-themes";
import { SWRConfig } from "swr";
// plane imports
// ui
import { ADMIN_BASE_PATH, DEFAULT_SWR_CONFIG } from "@plane/constants";
import { Toast } from "@plane/ui";
import { resolveGeneralTheme } from "@plane/utils";
// constants
// helpers
// lib
import { InstanceProvider } from "@/lib/instance-provider";
import { StoreProvider } from "@/lib/store-provider";
import { UserProvider } from "@/lib/user-provider";
// styles
import "@/styles/globals.css";
import "./globals.css";
const ToastWithTheme = () => {
const { resolvedTheme } = useTheme();
@@ -7,7 +7,7 @@ import { LogOut, UserCog2, Palette } from "lucide-react";
import { Menu, Transition } from "@headlessui/react";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import { AuthService } from "@plane/services";
import {AuthService } from "@plane/services";
import { Avatar } from "@plane/ui";
import { getFileURL, cn } from "@plane/utils";
// hooks
+6 -4
View File
@@ -67,8 +67,9 @@ export const InstanceHeader: FC = observer(() => {
{breadcrumbItems.length >= 0 && (
<div>
<Breadcrumbs>
<Breadcrumbs.Item
component={
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href="/general/"
label="Settings"
@@ -79,9 +80,10 @@ export const InstanceHeader: FC = observer(() => {
{breadcrumbItems.map(
(item) =>
item.title && (
<Breadcrumbs.Item
<Breadcrumbs.BreadcrumbItem
key={item.title}
component={<BreadcrumbLink href={item.href} label={item.title} />}
type="text"
link={<BreadcrumbLink href={item.href} label={item.title} />}
/>
)
)}
@@ -1,11 +1,11 @@
import { FC } from "react";
import { Info, X } from "lucide-react";
// plane constants
import { TAdminAuthErrorInfo } from "@plane/constants";
import { TAuthErrorInfo } from "@plane/constants";
type TAuthBanner = {
bannerData: TAdminAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAdminAuthErrorInfo | undefined) => void;
bannerData: TAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
};
export const AuthBanner: FC<TAuthBanner> = (props) => {
+2 -2
View File
@@ -4,7 +4,7 @@ import { FC, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { API_BASE_URL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { AuthService } from "@plane/services";
import { Button, Input, Spinner } from "@plane/ui";
// components
@@ -54,7 +54,7 @@ export const InstanceSignInForm: FC = (props) => {
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [formData, setFormData] = useState<TFormData>(defaultFromData);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorInfo, setErrorInfo] = useState<TAdminAuthErrorInfo | undefined>(undefined);
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
+2 -2
View File
@@ -3,7 +3,7 @@ import Image from "next/image";
import Link from "next/link";
import { KeyRound, Mails } from "lucide-react";
// plane packages
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
import { resolveGeneralTheme } from "@plane/utils";
// components
@@ -89,7 +89,7 @@ const errorCodeMessages: {
export const authErrorHandler = (
errorCode: EAdminAuthErrorCodes,
email?: string | undefined
): TAdminAuthErrorInfo | undefined => {
): TAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [
EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
+1 -1
View File
@@ -2,7 +2,7 @@ import set from "lodash/set";
import { observable, action, computed, makeObservable, runInAction } from "mobx";
// plane internal packages
import { EInstanceStatus, TInstanceStatus } from "@plane/constants";
import { InstanceService } from "@plane/services";
import {InstanceService} from "@plane/services";
import {
IInstance,
IInstanceAdmin,
@@ -1 +1 @@
export * from "ce/components/authentication/authentication-modes";
export * from "ce/components/authentication/authentication-modes";
+4 -6
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "0.26.1",
"version": "0.25.3",
"license": "AGPL-3.0",
"private": true,
"scripts": {
@@ -10,7 +10,6 @@
"build": "next build",
"preview": "next build && next start",
"start": "next start",
"format": "prettier --write .",
"lint": "eslint . --ext .ts,.tsx",
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
},
@@ -18,11 +17,10 @@
"@headlessui/react": "^1.7.19",
"@plane/constants": "*",
"@plane/hooks": "*",
"@plane/propel": "*",
"@plane/services": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@plane/services": "*",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
@@ -31,7 +29,7 @@
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "14.2.30",
"next": "^14.2.26",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
@@ -50,6 +48,6 @@
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.4",
"typescript": "5.8.3"
"typescript": "5.3.3"
}
}
+8 -2
View File
@@ -1,2 +1,8 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
module.exports = require("@plane/tailwind-config/postcss.config.js");
module.exports = {
plugins: {
"postcss-import": {},
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
},
};
+3 -9
View File
@@ -1,19 +1,13 @@
{
"extends": "@plane/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [
{
"name": "next"
}
],
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": {
"@/*": ["core/*"],
"@/public/*": ["public/*"],
"@/plane-admin/*": ["ce/*"],
"@/styles/*": ["styles/*"]
},
"strictNullChecks": true
"@/plane-admin/*": ["ce/*"]
}
},
"include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
+2 -8
View File
@@ -11,7 +11,7 @@ WORKDIR /app
RUN yarn global add turbo
COPY . .
RUN turbo prune --scope=web --scope=space --scope=admin --scope=live --docker
RUN turbo prune --scope=web --scope=space --scope=admin --docker
# *****************************************************************************
# STAGE 2: Install dependencies & build the project
@@ -53,7 +53,7 @@ ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1
RUN yarn turbo run build --filter=web --filter=space --filter=admin --filter=live
RUN yarn turbo run build --filter=web --filter=space --filter=admin
# *****************************************************************************
# STAGE 3: Copy the project and start it
@@ -87,8 +87,6 @@ RUN chmod +x ./api/bin/*
RUN chmod -R 777 ./api/
# NEXTJS BUILDS
COPY --from=installer /app/node_modules ./node_modules/
COPY --from=installer /app/web/next.config.js ./web/
COPY --from=installer /app/web/package.json ./web/
COPY --from=installer /app/web/.next/standalone ./web
@@ -107,10 +105,6 @@ COPY --from=installer /app/admin/.next/standalone ./admin
COPY --from=installer /app/admin/.next/static ./admin/admin/.next/static
COPY --from=installer /app/admin/public ./admin/admin/public
COPY --from=installer /app/live/package.json ./live/
COPY --from=installer /app/live/dist ./live/dist
# COPY --from=installer /app/live/node_modules ./live/node_modules
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
-8
View File
@@ -45,14 +45,6 @@ http {
proxy_pass http://localhost:3003/god-mode/;
}
location /live/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://localhost:3004/;
}
location /api/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
-10
View File
@@ -29,16 +29,6 @@ stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
environment=PORT=3003,HOSTNAME=0.0.0.0
[program:live]
command=node /app/live/dist/server.js
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
environment=PORT=3004,HOSTNAME=0.0.0.0,API_BASE_URL="http://localhost:8000"
[program:migrator]
directory=/app/api
command=sh -c "./bin/docker-entrypoint-migrator.sh"
-25
View File
@@ -1,25 +0,0 @@
[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
+7 -16
View File
@@ -1,7 +1,7 @@
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
CORS_ALLOWED_ORIGINS="http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3100"
CORS_ALLOWED_ORIGINS="http://localhost"
# Database Settings
POSTGRES_USER="plane"
@@ -27,7 +27,7 @@ RABBITMQ_VHOST="plane"
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
AWS_SECRET_ACCESS_KEY="secret-key"
AWS_S3_ENDPOINT_URL="http://localhost:9000"
AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
# Changing this requires change in the nginx.conf for uploads if using minio setup
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
@@ -37,31 +37,22 @@ FILE_SIZE_LIMIT=5242880
DOCKERIZED=1 # deprecated
# set to 1 If using the pre-configured minio setup
USE_MINIO=0
USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# Email redirections and minio domain settings
WEB_URL="http://localhost:8000"
WEB_URL="http://localhost"
# Gunicorn Workers
GUNICORN_WORKERS=2
# Base URLs
ADMIN_BASE_URL="http://localhost:3001"
ADMIN_BASE_PATH="/god-mode"
ADMIN_BASE_URL=
SPACE_BASE_URL=
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_AFTER_DAYS=60
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "0.26.1",
"version": "0.25.3",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+1 -1
View File
@@ -15,4 +15,4 @@ from .state import StateLiteSerializer, StateSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
from .intake import IntakeIssueSerializer
from .estimate import EstimatePointSerializer
from .estimate import EstimatePointSerializer
+6
View File
@@ -48,6 +48,11 @@ class CycleSerializer(BaseSerializer):
if not project_id:
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(
date=str(data.get("start_date").date()),
project_id=project_id,
@@ -56,6 +61,7 @@ class CycleSerializer(BaseSerializer):
data["end_date"] = convert_to_utc(
date=str(data.get("end_date", None).date()),
project_id=project_id,
is_start_date_end_date_equal=is_start_date_end_date_equal,
)
return data
+6 -9
View File
@@ -160,15 +160,12 @@ class IssueSerializer(BaseSerializer):
else:
try:
# Then assign it to default assignee, if it is a valid assignee
if (
default_assignee_id is not None
and ProjectMember.objects.filter(
member_id=default_assignee_id,
project_id=project_id,
role__gte=15,
is_active=True,
).exists()
):
if default_assignee_id is not None and ProjectMember.objects.filter(
member_id=default_assignee_id,
project_id=project_id,
role__gte=15,
is_active=True
).exists():
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,
+8 -11
View File
@@ -788,7 +788,6 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -800,7 +799,6 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -849,7 +847,6 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
)
)
)
old_cycle = old_cycle.first()
estimate_type = Project.objects.filter(
workspace__slug=slug,
@@ -969,7 +966,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
)
estimate_completion_chart = burndown_plot(
queryset=old_cycle,
queryset=old_cycle.first(),
slug=slug,
project_id=project_id,
plot_type="points",
@@ -1117,7 +1114,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle,
queryset=old_cycle.first(),
slug=slug,
project_id=project_id,
plot_type="issues",
@@ -1129,12 +1126,12 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
).first()
current_cycle.progress_snapshot = {
"total_issues": old_cycle.total_issues,
"completed_issues": old_cycle.completed_issues,
"cancelled_issues": old_cycle.cancelled_issues,
"started_issues": old_cycle.started_issues,
"unstarted_issues": old_cycle.unstarted_issues,
"backlog_issues": old_cycle.backlog_issues,
"total_issues": old_cycle.first().total_issues,
"completed_issues": old_cycle.first().completed_issues,
"cancelled_issues": old_cycle.first().cancelled_issues,
"started_issues": old_cycle.first().started_issues,
"unstarted_issues": old_cycle.first().unstarted_issues,
"backlog_issues": old_cycle.first().backlog_issues,
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
+1 -2
View File
@@ -20,7 +20,6 @@ from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.db.models.intake import SourceType
class IntakeIssueAPIEndpoint(BaseAPIView):
@@ -126,7 +125,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
intake_id=intake.id,
project_id=project_id,
issue=issue,
source=SourceType.IN_APP,
source=request.data.get("source", "IN-APP"),
)
# Create an Issue Activity
issue_activity.delay(
+1 -7
View File
@@ -58,7 +58,7 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
class WorkspaceIssueAPIEndpoint(BaseAPIView):
"""
@@ -692,9 +692,6 @@ class IssueLinkAPIEndpoint(BaseAPIView):
serializer = IssueLinkSerializer(data=request.data)
if serializer.is_valid():
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.created_by_id = request.data.get("created_by", request.user.id)
@@ -722,9 +719,6 @@ class IssueLinkAPIEndpoint(BaseAPIView):
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay(
type="link.activity.updated",
requested_data=requested_data,
+4 -4
View File
@@ -172,14 +172,14 @@ class ProjectAPIEndpoint(BaseAPIView):
states = [
{
"name": "Backlog",
"color": "#60646C",
"color": "#A3A3A3",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#60646C",
"color": "#3A3A3A",
"sequence": 25000,
"group": "unstarted",
},
@@ -191,13 +191,13 @@ class ProjectAPIEndpoint(BaseAPIView):
},
{
"name": "Done",
"color": "#46A758",
"color": "#16A34A",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#9AA4BC",
"color": "#EF4444",
"sequence": 55000,
"group": "cancelled",
},
-9
View File
@@ -1,7 +1,5 @@
from .base import BaseSerializer
from plane.db.models import APIToken, APIActivityLog
from rest_framework import serializers
from django.utils import timezone
class APITokenSerializer(BaseSerializer):
@@ -19,17 +17,10 @@ class APITokenSerializer(BaseSerializer):
class APITokenReadSerializer(BaseSerializer):
is_active = serializers.SerializerMethodField()
class Meta:
model = APIToken
exclude = ("token",)
def get_is_active(self, obj: APIToken) -> bool:
if obj.expired_at is None:
return True
return timezone.now() < obj.expired_at
class APIActivityLogSerializer(BaseSerializer):
class Meta:
+6
View File
@@ -25,6 +25,11 @@ class CycleWriteSerializer(BaseSerializer):
or (self.instance and self.instance.project_id)
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(
date=str(data.get("start_date").date()),
project_id=project_id,
@@ -33,6 +38,7 @@ class CycleWriteSerializer(BaseSerializer):
data["end_date"] = convert_to_utc(
date=str(data.get("end_date", None).date()),
project_id=project_id,
is_start_date_end_date_equal=is_start_date_end_date_equal,
)
return data
@@ -53,7 +53,6 @@ def get_entity_model_and_serializer(entity_type):
}
return entity_map.get(entity_type, (None, None))
class UserFavoriteSerializer(serializers.ModelSerializer):
entity_data = serializers.SerializerMethodField()
+2 -5
View File
@@ -148,13 +148,10 @@ class ProjectMemberAdminSerializer(BaseSerializer):
fields = "__all__"
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
original_role = serializers.IntegerField(source='role', read_only=True)
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
class Meta:
model = ProjectMember
fields = ("id", "role", "member", "project", "original_role", "created_at")
read_only_fields = ["original_role", "created_at"]
fields = ("id", "role", "member", "project")
class ProjectMemberInviteSerializer(BaseSerializer):
+1 -4
View File
@@ -1,13 +1,11 @@
# Module imports
from .base import BaseSerializer
from rest_framework import serializers
from plane.db.models import State
class StateSerializer(BaseSerializer):
order = serializers.FloatField(required=False)
class Meta:
model = State
fields = [
@@ -20,7 +18,6 @@ class StateSerializer(BaseSerializer):
"default",
"description",
"sequence",
"order",
]
read_only_fields = ["workspace", "project"]
-16
View File
@@ -3,22 +3,11 @@ from rest_framework import serializers
# Module import
from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite
from plane.utils.url import contains_url
from .base import 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:
model = User
# Exclude password field from the serializer
@@ -110,16 +99,11 @@ class UserMeSettingsSerializer(BaseSerializer):
workspace_member__member=obj.id,
workspace_member__is_active=True,
).first()
logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else ""
return {
"last_workspace_id": profile.last_workspace_id,
"last_workspace_slug": (
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_slug": (
workspace.slug if workspace is not None else ""
+9 -25
View File
@@ -1,5 +1,7 @@
# Third party imports
from rest_framework import serializers
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
@@ -23,12 +25,10 @@ from plane.db.models import (
WorkspaceUserPreference,
)
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.utils.url import contains_url
# Django imports
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
import re
class WorkSpaceSerializer(DynamicBaseSerializer):
@@ -36,21 +36,10 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
logo_url = serializers.CharField(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):
# Check if the slug is restricted
if value in RESTRICTED_WORKSPACE_SLUGS:
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
class Meta:
@@ -159,6 +148,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
return value
def create(self, validated_data):
# Filtering the WorkspaceUserLink with the given url to check if the link already exists.
@@ -167,7 +157,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
workspace_user_link = WorkspaceUserLink.objects.filter(
url=url,
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():
@@ -183,8 +173,10 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
url = validated_data.get("url")
workspace_user_link = WorkspaceUserLink.objects.filter(
url=url, workspace_id=instance.workspace_id, owner=instance.owner
)
url=url,
workspace_id=instance.workspace_id,
owner=instance.owner
)
if workspace_user_link.exclude(pk=instance.id).exists():
raise serializers.ValidationError(
@@ -193,10 +185,8 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
return super().update(instance, validated_data)
class IssueRecentVisitSerializer(serializers.ModelSerializer):
project_identifier = serializers.SerializerMethodField()
assignees = serializers.SerializerMethodField()
class Meta:
model = Issue
@@ -214,14 +204,8 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
def get_project_identifier(self, obj):
project = obj.project
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
)
)
return project.identifier if project else None
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
-36
View File
@@ -6,14 +6,8 @@ from plane.app.views import (
AnalyticViewViewset,
SavedAnalyticEndpoint,
ExportAnalyticsEndpoint,
AdvanceAnalyticsEndpoint,
AdvanceAnalyticsStatsEndpoint,
AdvanceAnalyticsChartEndpoint,
DefaultAnalyticsEndpoint,
ProjectStatsEndpoint,
ProjectAdvanceAnalyticsEndpoint,
ProjectAdvanceAnalyticsStatsEndpoint,
ProjectAdvanceAnalyticsChartEndpoint,
)
@@ -55,34 +49,4 @@ urlpatterns = [
ProjectStatsEndpoint.as_view(),
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",
),
]
+3 -3
View File
@@ -4,14 +4,14 @@ from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
urlpatterns = [
# API Tokens
path(
"users/api-tokens/",
"workspaces/<str:slug>/api-tokens/",
ApiTokenEndpoint.as_view(),
name="api-tokens",
),
path(
"users/api-tokens/<uuid:pk>/",
"workspaces/<str:slug>/api-tokens/<uuid:pk>/",
ApiTokenEndpoint.as_view(),
name="api-tokens-details",
name="api-tokens",
),
path(
"workspaces/<str:slug>/service-api-tokens/",
-19
View File
@@ -12,9 +12,6 @@ from plane.app.views import (
AssetRestoreEndpoint,
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
@@ -84,21 +81,5 @@ urlpatterns = [
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/<uuid:entity_id>/bulk/",
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",
),
]
-15
View File
@@ -106,9 +106,6 @@ from .asset.v2 import (
AssetRestoreEndpoint,
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
from .issue.base import (
IssueListEndpoint,
@@ -202,18 +199,6 @@ from .analytic.base import (
ProjectStatsEndpoint,
)
from .analytic.advance import (
AdvanceAnalyticsEndpoint,
AdvanceAnalyticsStatsEndpoint,
AdvanceAnalyticsChartEndpoint,
)
from .analytic.project_analytics import (
ProjectAdvanceAnalyticsEndpoint,
ProjectAdvanceAnalyticsStatsEndpoint,
ProjectAdvanceAnalyticsChartEndpoint,
)
from .notification.base import (
NotificationViewSet,
UnreadNotificationEndpoint,
@@ -1,366 +0,0 @@
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)
@@ -1,421 +0,0 @@
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)
+21 -13
View File
@@ -1,23 +1,24 @@
# Python import
from uuid import uuid4
from typing import Optional
# Third party
from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework import status
# Module import
from .base import BaseAPIView
from plane.db.models import APIToken, Workspace
from plane.app.serializers import APITokenSerializer, APITokenReadSerializer
from plane.app.permissions import WorkspaceEntityPermission
from plane.app.permissions import WorkspaceOwnerPermission
class ApiTokenEndpoint(BaseAPIView):
def post(self, request: Request) -> Response:
permission_classes = [WorkspaceOwnerPermission]
def post(self, request, slug):
label = request.data.get("label", str(uuid4().hex))
description = request.data.get("description", "")
workspace = Workspace.objects.get(slug=slug)
expired_at = request.data.get("expired_at", None)
# Check the user type
@@ -27,6 +28,7 @@ class ApiTokenEndpoint(BaseAPIView):
label=label,
description=description,
user=request.user,
workspace=workspace,
user_type=user_type,
expired_at=expired_at,
)
@@ -35,23 +37,29 @@ class ApiTokenEndpoint(BaseAPIView):
# Token will be only visible while creating
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request: Request, pk: Optional[str] = None) -> Response:
def get(self, request, slug, pk=None):
if pk is None:
api_tokens = APIToken.objects.filter(user=request.user, is_service=False)
api_tokens = APIToken.objects.filter(
user=request.user, workspace__slug=slug, is_service=False
)
serializer = APITokenReadSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
api_tokens = APIToken.objects.get(user=request.user, pk=pk)
api_tokens = APIToken.objects.get(
user=request.user, workspace__slug=slug, pk=pk
)
serializer = APITokenReadSerializer(api_tokens)
return Response(serializer.data, status=status.HTTP_200_OK)
def delete(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
def delete(self, request, slug, pk):
api_token = APIToken.objects.get(
workspace__slug=slug, user=request.user, pk=pk, is_service=False
)
api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk)
def patch(self, request, slug, pk):
api_token = APIToken.objects.get(workspace__slug=slug, user=request.user, pk=pk)
serializer = APITokenSerializer(api_token, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
@@ -60,9 +68,9 @@ class ApiTokenEndpoint(BaseAPIView):
class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
permission_classes = [WorkspaceOwnerPermission]
def post(self, request: Request, slug: str) -> Response:
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
api_token = APIToken.objects.filter(
+4 -68
View File
@@ -137,7 +137,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
if type not in allowed_types:
return Response(
{
"error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.",
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
"status": False,
},
status=status.HTTP_400_BAD_REQUEST,
@@ -351,7 +351,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
if type not in allowed_types:
return Response(
{
"error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.",
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
"status": False,
},
status=status.HTTP_400_BAD_REQUEST,
@@ -552,7 +552,7 @@ class ProjectAssetEndpoint(BaseAPIView):
if type not in allowed_types:
return Response(
{
"error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.",
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
"status": False,
},
status=status.HTTP_400_BAD_REQUEST,
@@ -683,7 +683,7 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
# For some cases, the bulk api is called after the issue is deleted creating
# an integrity error
try:
assets.update(issue_id=entity_id, project_id=project_id)
assets.update(issue_id=entity_id)
except IntegrityError:
pass
@@ -707,67 +707,3 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
pass
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)
+60 -90
View File
@@ -117,7 +117,6 @@ class CycleViewSet(BaseViewSet):
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -130,7 +129,6 @@ class CycleViewSet(BaseViewSet):
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -143,7 +141,6 @@ class CycleViewSet(BaseViewSet):
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -269,7 +266,9 @@ class CycleViewSet(BaseViewSet):
"created_by",
)
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(data, datetime_fields, project_timezone)
data = user_timezone_converter(
data, datetime_fields, project_timezone
)
return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@@ -416,7 +415,9 @@ class CycleViewSet(BaseViewSet):
project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter(cycle, datetime_fields, project_timezone)
cycle = user_timezone_converter(
cycle, datetime_fields, project_timezone
)
# Send the model activity
model_activity.delay(
@@ -573,12 +574,16 @@ class CycleDateCheckEndpoint(BaseAPIView):
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(
date=str(start_date), project_id=project_id, is_start_date=True
)
end_date = convert_to_utc(
date=str(end_date),
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
@@ -663,7 +668,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -728,7 +732,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
)
)
)
old_cycle = old_cycle.first()
estimate_type = Project.objects.filter(
workspace__slug=slug,
@@ -847,7 +850,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
)
estimate_completion_chart = burndown_plot(
queryset=old_cycle,
queryset=old_cycle.first(),
slug=slug,
project_id=project_id,
plot_type="points",
@@ -994,7 +997,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle,
queryset=old_cycle.first(),
slug=slug,
project_id=project_id,
plot_type="issues",
@@ -1006,12 +1009,12 @@ class TransferCycleIssueEndpoint(BaseAPIView):
).first()
current_cycle.progress_snapshot = {
"total_issues": old_cycle.total_issues,
"completed_issues": old_cycle.completed_issues,
"cancelled_issues": old_cycle.cancelled_issues,
"started_issues": old_cycle.started_issues,
"unstarted_issues": old_cycle.unstarted_issues,
"backlog_issues": old_cycle.backlog_issues,
"total_issues": old_cycle.first().total_issues,
"completed_issues": old_cycle.first().completed_issues,
"cancelled_issues": old_cycle.first().cancelled_issues,
"started_issues": old_cycle.first().started_issues,
"unstarted_issues": old_cycle.first().unstarted_issues,
"backlog_issues": old_cycle.first().backlog_issues,
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
@@ -1119,13 +1122,6 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
class CycleProgressEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
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 = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
@@ -1176,60 +1172,53 @@ 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()
unstarted_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="unstarted",
).count()
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()
started_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="started",
).count()
unstarted_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="unstarted",
).count()
cancelled_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="cancelled",
).count()
started_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="started",
).count()
completed_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="completed",
).count()
cancelled_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="cancelled",
).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()
completed_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="completed",
).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(
{
@@ -1290,25 +1279,6 @@ class CycleAnalyticsEndpoint(BaseAPIView):
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(
workspace__slug=slug,
pk=project_id,
-1
View File
@@ -29,7 +29,6 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina
from plane.app.permissions import allow_permission, ROLE
from plane.utils.host import base_host
class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer
model = CycleIssue
+28 -41
View File
@@ -11,7 +11,8 @@ from rest_framework.response import Response
# Module import
from plane.app.permissions import ROLE, allow_permission
from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
from plane.app.serializers import (ProjectLiteSerializer,
WorkspaceLiteSerializer)
from plane.db.models import Project, Workspace
from plane.license.utils.instance_value import get_configuration_value
from plane.utils.exception_logger import log_exception
@@ -21,7 +22,6 @@ from ..base import BaseAPIView
class LLMProvider:
"""Base class for LLM provider configurations"""
name: str = ""
models: List[str] = []
default_model: str = ""
@@ -34,13 +34,11 @@ class LLMProvider:
"default_model": cls.default_model,
}
class OpenAIProvider(LLMProvider):
name = "OpenAI"
models = ["gpt-3.5-turbo", "gpt-4o-mini", "gpt-4o", "o1-mini", "o1-preview"]
default_model = "gpt-4o-mini"
class AnthropicProvider(LLMProvider):
name = "Anthropic"
models = [
@@ -51,45 +49,40 @@ class AnthropicProvider(LLMProvider):
"claude-2.1",
"claude-2",
"claude-instant-1.2",
"claude-instant-1",
"claude-instant-1"
]
default_model = "claude-3-sonnet-20240229"
class GeminiProvider(LLMProvider):
name = "Gemini"
models = ["gemini-pro", "gemini-1.5-pro-latest", "gemini-pro-vision"]
default_model = "gemini-pro"
SUPPORTED_PROVIDERS = {
"openai": OpenAIProvider,
"anthropic": AnthropicProvider,
"gemini": GeminiProvider,
}
def get_llm_config() -> Tuple[str | None, str | None, str | None]:
"""
Helper to get LLM configuration values, returns:
- api_key, model, provider
"""
api_key, provider_key, model = get_configuration_value(
[
{
"key": "LLM_API_KEY",
"default": os.environ.get("LLM_API_KEY", None),
},
{
"key": "LLM_PROVIDER",
"default": os.environ.get("LLM_PROVIDER", "openai"),
},
{
"key": "LLM_MODEL",
"default": os.environ.get("LLM_MODEL", None),
},
]
)
api_key, provider_key, model = get_configuration_value([
{
"key": "LLM_API_KEY",
"default": os.environ.get("LLM_API_KEY", None),
},
{
"key": "LLM_PROVIDER",
"default": os.environ.get("LLM_PROVIDER", "openai"),
},
{
"key": "LLM_MODEL",
"default": os.environ.get("LLM_MODEL", None),
},
])
provider = SUPPORTED_PROVIDERS.get(provider_key.lower())
if not provider:
@@ -106,20 +99,16 @@ def get_llm_config() -> Tuple[str | None, str | None, str | None]:
# Validate model is supported by provider
if model not in provider.models:
log_exception(
ValueError(
f"Model {model} not supported by {provider.name}. "
f"Supported models: {', '.join(provider.models)}"
)
)
log_exception(ValueError(
f"Model {model} not supported by {provider.name}. "
f"Supported models: {', '.join(provider.models)}"
))
return None, None, None
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"""
final_text = task + "\n" + prompt
try:
@@ -129,7 +118,10 @@ def get_llm_response(
client = OpenAI(api_key=api_key)
chat_completion = client.chat.completions.create(
model=model, messages=[{"role": "user", "content": final_text}]
model=model,
messages=[
{"role": "user", "content": final_text}
]
)
text = chat_completion.choices[0].message.content
return text, None
@@ -143,7 +135,6 @@ def get_llm_response(
else:
return None, f"Error occurred while generating response from {provider}"
class GPTIntegrationEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id):
@@ -161,9 +152,7 @@ class GPTIntegrationEndpoint(BaseAPIView):
{"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:
return Response(
{"error": "An internal error has occurred."},
@@ -201,9 +190,7 @@ class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
{"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:
return Response(
{"error": "An internal error has occurred."},
+3 -2
View File
@@ -44,7 +44,6 @@ from plane.app.views.base import BaseAPIView
from plane.utils.timezone_converter import user_timezone_converter
from plane.utils.global_paginator import paginate
from plane.utils.host import base_host
from plane.db.models.intake import SourceType
class IntakeViewSet(BaseViewSet):
@@ -279,7 +278,7 @@ class IntakeIssueViewSet(BaseViewSet):
intake_id=intake_id.id,
project_id=project_id,
issue_id=serializer.data["id"],
source=SourceType.IN_APP,
source=request.data.get("source", "IN-APP"),
)
# Create an Issue Activity
issue_activity.delay(
@@ -409,6 +408,7 @@ class IntakeIssueViewSet(BaseViewSet):
)
if issue_serializer.is_valid():
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
@@ -607,6 +607,7 @@ class IntakeIssueViewSet(BaseViewSet):
class IntakeWorkItemDescriptionVersionEndpoint(BaseAPIView):
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
@@ -38,7 +38,6 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina
from plane.app.permissions import allow_permission, ROLE
from plane.utils.error_codes import ERROR_CODES
from plane.utils.host import base_host
# Module imports
from .. import BaseViewSet, BaseAPIView
@@ -23,7 +23,6 @@ from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.utils.host import base_host
class IssueAttachmentEndpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer
model = FileAsset
+1 -26
View File
@@ -944,33 +944,9 @@ class IssueDetailEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
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
project_member_subquery = ProjectMember.objects.filter(
project_id=OuterRef("project_id"),
member=self.request.user,
is_active=True,
).filter(
Q(role__gt=ROLE.GUEST.value)
| Q(
role=ROLE.GUEST.value, project__guest_view_all_features=True
)
)
# Main issue query
issue = (
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
.filter(
Q(Exists(project_member_subquery))
| 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,
)
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
@@ -1038,7 +1014,6 @@ class IssueDetailEndpoint(BaseAPIView):
.values("count")
)
)
issue = issue.filter(**filters)
order_by_param = request.GET.get("order_by", "-created_at")
# Issue queryset
@@ -19,7 +19,6 @@ from plane.db.models import IssueComment, ProjectMember, CommentReaction, Projec
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.host import base_host
class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
-17
View File
@@ -15,10 +15,8 @@ from plane.app.serializers import IssueLinkSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import IssueLink
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
from plane.utils.host import base_host
class IssueLinkViewSet(BaseViewSet):
permission_classes = [ProjectEntityPermission]
@@ -45,9 +43,6 @@ class IssueLinkViewSet(BaseViewSet):
serializer = IssueLinkSerializer(data=request.data)
if serializer.is_valid():
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(
type="link.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
@@ -59,10 +54,6 @@ class IssueLinkViewSet(BaseViewSet):
notification=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.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -74,14 +65,9 @@ class IssueLinkViewSet(BaseViewSet):
current_instance = json.dumps(
IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder
)
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay(
type="link.activity.updated",
requested_data=requested_data,
@@ -93,9 +79,6 @@ class IssueLinkViewSet(BaseViewSet):
notification=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.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -17,7 +17,6 @@ from plane.db.models import IssueReaction
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.host import base_host
class IssueReactionViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer
model = IssueReaction
@@ -29,7 +29,6 @@ from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.issue_relation_mapper import get_actual_relation
from plane.utils.host import base_host
class IssueRelationViewSet(BaseViewSet):
serializer_class = IssueRelationSerializer
model = IssueRelation
@@ -23,8 +23,6 @@ from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.timezone_converter import user_timezone_converter
from collections import defaultdict
from plane.utils.host import base_host
from plane.utils.order_queryset import order_issue_queryset
class SubIssuesEndpoint(BaseAPIView):
permission_classes = [ProjectEntityPermission]
@@ -104,15 +102,6 @@ class SubIssuesEndpoint(BaseAPIView):
.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
result = defaultdict(list)
for sub_issue in sub_issues:
@@ -149,26 +138,6 @@ class SubIssuesEndpoint(BaseAPIView):
sub_issues = user_timezone_converter(
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(
{"sub_issues": sub_issues, "state_distribution": result},
status=status.HTTP_200_OK,
+5 -14
View File
@@ -63,7 +63,6 @@ from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.host import base_host
class ModuleViewSet(BaseViewSet):
model = Module
webhook_event = "module"
@@ -711,31 +710,23 @@ class ModuleViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
module_queryset = self.get_queryset().filter(pk=pk)
module = self.get_queryset().filter(pk=pk)
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:
if module.first().archived_at:
return Response(
{"error": "Archived module cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
current_instance = json.dumps(
ModuleSerializer(current_module).data, cls=DjangoJSONEncoder
ModuleSerializer(module.first()).data, cls=DjangoJSONEncoder
)
serializer = ModuleWriteSerializer(
current_module, data=request.data, partial=True
module.first(), data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
module = module_queryset.values(
module = module.values(
# Required fields
"id",
"workspace_id",
+1 -6
View File
@@ -36,7 +36,6 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina
from .. import BaseViewSet
from plane.utils.host import base_host
class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
@@ -281,11 +280,7 @@ class ModuleIssueViewSet(BaseViewSet):
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
{
"module_name": module_issue.first().module.name
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()),
notification=True,
+1 -8
View File
@@ -42,7 +42,6 @@ from plane.bgtasks.page_version_task import page_version
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.copy_s3_object import copy_s3_objects
def unarchive_archive_page_and_descendants(page_id, archived_at):
# Your SQL query
sql = """
@@ -199,7 +198,7 @@ class PageViewSet(BaseViewSet):
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
"""
@@ -573,12 +572,6 @@ class PageDuplicateEndpoint(BaseAPIView):
pk=page_id, workspace__slug=slug, projects__id=project_id
).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
project_ids = ProjectPage.objects.filter(page_id=page_id).values_list(
"project_id", flat=True
+7 -13
View File
@@ -275,14 +275,14 @@ class ProjectViewSet(BaseViewSet):
states = [
{
"name": "Backlog",
"color": "#60646C",
"color": "#A3A3A3",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#60646C",
"color": "#3A3A3A",
"sequence": 25000,
"group": "unstarted",
},
@@ -294,13 +294,13 @@ class ProjectViewSet(BaseViewSet):
},
{
"name": "Done",
"color": "#46A758",
"color": "#16A34A",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#9AA4BC",
"color": "#EF4444",
"sequence": 55000,
"group": "cancelled",
},
@@ -341,10 +341,7 @@ class ProjectViewSet(BaseViewSet):
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{
"name": "The project name is already taken",
"code": "PROJECT_NAME_ALREADY_EXIST",
},
{"name": "The project name is already taken"},
status=status.HTTP_409_CONFLICT,
)
except Workspace.DoesNotExist:
@@ -353,10 +350,7 @@ class ProjectViewSet(BaseViewSet):
)
except serializers.ValidationError:
return Response(
{
"identifier": "The project identifier is already taken",
"code": "PROJECT_IDENTIFIER_ALREADY_EXIST",
},
{"identifier": "The project identifier is already taken"},
status=status.HTTP_409_CONFLICT,
)
@@ -451,7 +445,7 @@ class ProjectViewSet(BaseViewSet):
is_active=True,
).exists()
):
project = Project.objects.get(pk=pk, workspace__slug=slug)
project = Project.objects.get(pk=pk)
project.delete()
webhook_activity.delay(
event="project",
@@ -29,7 +29,6 @@ from plane.db.models import (
from plane.db.models.project import ProjectNetwork
from plane.utils.host import base_host
class ProjectInvitationsViewset(BaseViewSet):
serializer_class = ProjectMemberInviteSerializer
model = ProjectMemberInvite
+1 -7
View File
@@ -168,8 +168,6 @@ class ProjectMemberViewSet(BaseViewSet):
workspace__slug=slug,
member__is_bot=False,
is_active=True,
member__member_workspace__workspace__slug=slug,
member__member_workspace__is_active=True,
).select_related("project", "member", "workspace")
serializer = ProjectMemberRoleSerializer(
@@ -315,11 +313,7 @@ class UserProjectRolesEndpoint(BaseAPIView):
def get(self, request, slug):
project_members = ProjectMember.objects.filter(
workspace__slug=slug,
member_id=request.user.id,
is_active=True,
member__member_workspace__workspace__slug=slug,
member__member_workspace__is_active=True,
workspace__slug=slug, member_id=request.user.id, is_active=True
).values("project_id", "role")
project_members = {
+35 -99
View File
@@ -1,5 +1,5 @@
# Django imports
from django.db.models import Q, QuerySet
from django.db.models import Q
# Third party imports
from rest_framework import status
@@ -12,95 +12,6 @@ from plane.utils.issue_search import search_issues
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):
query = request.query_params.get("search", False)
workspace_search = request.query_params.get("workspace_search", "false")
@@ -110,6 +21,7 @@ class IssueSearchEndpoint(BaseAPIView):
module = request.query_params.get("module", False)
sub_issue = request.query_params.get("sub_issue", "false")
target_date = request.query_params.get("target_date", True)
issue_id = request.query_params.get("issue_id", False)
issues = Issue.issue_objects.filter(
@@ -120,28 +32,52 @@ class IssueSearchEndpoint(BaseAPIView):
)
if workspace_search == "false":
issues = self.filter_issues_by_project(project_id, issues)
issues = issues.filter(project_id=project_id)
if query:
issues = self.search_issues_by_query(query, issues)
issues = search_issues(query, issues)
if parent == "true" and issue_id:
issues = self.search_issues_and_excluding_parent(issues, issue_id)
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)
)
if issue_relation == "true" and issue_id:
issues = self.filter_issues_excluding_related_issues(issue_id, 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),
)
if sub_issue == "true" and issue_id:
issues = self.filter_root_issues_only(issue_id, issues)
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))
if cycle == "true":
issues = self.exclude_issues_in_cycles(issues)
issues = issues.exclude(
Q(issue_cycle__isnull=False) & Q(issue_cycle__deleted_at__isnull=True)
)
if module:
issues = self.exclude_issues_in_module(issues, module)
issues = issues.exclude(
Q(issue_module__module=module)
& Q(issue_module__deleted_at__isnull=True)
)
if target_date == "none":
issues = self.filter_issues_without_target_date(issues)
issues = issues.filter(target_date__isnull=True)
if ProjectMember.objects.filter(
project_id=project_id, member=self.request.user, is_active=True, role=5
-14
View File
@@ -1,6 +1,5 @@
# Python imports
from itertools import groupby
from collections import defaultdict
# Django imports
from django.db.utils import IntegrityError
@@ -75,19 +74,7 @@ class StateViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
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)
if grouped == "true":
state_dict = {}
for key, value in groupby(
@@ -96,7 +83,6 @@ class StateViewSet(BaseViewSet):
):
state_dict[str(key)] = list(value)
return Response(state_dict, status=status.HTTP_200_OK)
return Response(states, status=status.HTTP_200_OK)
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
+120 -146
View File
@@ -24,152 +24,125 @@ class TimezoneEndpoint(APIView):
@method_decorator(cache_page(60 * 60 * 2))
def get(self, request):
timezone_locations = [
("Midway Island", "Pacific/Midway"), # UTC-11:00
("American Samoa", "Pacific/Pago_Pago"), # UTC-11:00
("Hawaii", "Pacific/Honolulu"), # UTC-10:00
("Aleutian Islands", "America/Adak"), # UTC-10:00 (DST: UTC-09:00)
("Marquesas Islands", "Pacific/Marquesas"), # UTC-09:30
("Alaska", "America/Anchorage"), # UTC-09:00 (DST: UTC-08: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)
(
"Mountain Time (US and Canada)",
"America/Denver",
), # UTC-07:00 (DST: UTC-06:00)
("Arizona", "America/Phoenix"), # UTC-07:00
("Chihuahua, Mazatlan", "America/Chihuahua"), # UTC-07:00 (DST: UTC-06:00)
(
"Central Time (US and Canada)",
"America/Chicago",
), # UTC-06:00 (DST: UTC-05:00)
("Saskatchewan", "America/Regina"), # UTC-06:00
(
"Guadalajara, Mexico City, Monterrey",
"America/Mexico_City",
), # UTC-06:00 (DST: UTC-05:00)
("Tegucigalpa, Honduras", "America/Tegucigalpa"), # UTC-06:00
("Costa Rica", "America/Costa_Rica"), # UTC-06:00
(
"Eastern Time (US and Canada)",
"America/New_York",
), # UTC-05:00 (DST: UTC-04:00)
("Lima", "America/Lima"), # UTC-05:00
("Bogota", "America/Bogota"), # UTC-05:00
("Quito", "America/Guayaquil"), # UTC-05:00
("Chetumal", "America/Cancun"), # UTC-05:00 (DST: UTC-04:00)
("Caracas (Old Venezuela Time)", "America/Caracas"), # UTC-04:30
("Atlantic Time (Canada)", "America/Halifax"), # UTC-04:00 (DST: UTC-03:00)
("Caracas", "America/Caracas"), # UTC-04:00
("Santiago", "America/Santiago"), # UTC-04:00 (DST: UTC-03:00)
("La Paz", "America/La_Paz"), # UTC-04:00
("Manaus", "America/Manaus"), # UTC-04:00
("Georgetown", "America/Guyana"), # UTC-04:00
("Bermuda", "Atlantic/Bermuda"), # UTC-04:00 (DST: UTC-03:00)
(
"Newfoundland Time (Canada)",
"America/St_Johns",
), # UTC-03:30 (DST: UTC-02:30)
("Buenos Aires", "America/Argentina/Buenos_Aires"), # UTC-03:00
("Brasilia", "America/Sao_Paulo"), # UTC-03:00
("Greenland", "America/Godthab"), # UTC-03:00 (DST: UTC-02:00)
("Montevideo", "America/Montevideo"), # UTC-03:00
("Falkland Islands", "Atlantic/Stanley"), # UTC-03:00
(
"South Georgia and the South Sandwich Islands",
"Atlantic/South_Georgia",
), # UTC-02:00
("Azores", "Atlantic/Azores"), # UTC-01:00 (DST: UTC+00:00)
("Cape Verde Islands", "Atlantic/Cape_Verde"), # UTC-01:00
("Dublin", "Europe/Dublin"), # UTC+00:00 (DST: UTC+01:00)
("Reykjavik", "Atlantic/Reykjavik"), # UTC+00:00
("Lisbon", "Europe/Lisbon"), # UTC+00:00 (DST: UTC+01:00)
("Monrovia", "Africa/Monrovia"), # UTC+00:00
("Casablanca", "Africa/Casablanca"), # UTC+00:00 (DST: UTC+01:00)
(
"Central European Time (Berlin, Rome, Paris)",
"Europe/Paris",
), # UTC+01:00 (DST: UTC+02:00)
("West Central Africa", "Africa/Lagos"), # UTC+01:00
("Algiers", "Africa/Algiers"), # UTC+01:00
("Lagos", "Africa/Lagos"), # UTC+01:00
("Tunis", "Africa/Tunis"), # UTC+01:00
(
"Eastern European Time (Cairo, Helsinki, Kyiv)",
"Europe/Kiev",
), # UTC+02:00 (DST: UTC+03:00)
("Athens", "Europe/Athens"), # UTC+02:00 (DST: UTC+03:00)
("Jerusalem", "Asia/Jerusalem"), # UTC+02:00 (DST: UTC+03:00)
("Johannesburg", "Africa/Johannesburg"), # UTC+02:00
("Harare, Pretoria", "Africa/Harare"), # UTC+02:00
("Moscow Time", "Europe/Moscow"), # UTC+03:00
("Baghdad", "Asia/Baghdad"), # UTC+03:00
("Nairobi", "Africa/Nairobi"), # UTC+03:00
("Kuwait, Riyadh", "Asia/Riyadh"), # UTC+03:00
("Tehran", "Asia/Tehran"), # UTC+03:30 (DST: UTC+04:30)
("Abu Dhabi", "Asia/Dubai"), # UTC+04:00
("Baku", "Asia/Baku"), # UTC+04:00 (DST: UTC+05:00)
("Yerevan", "Asia/Yerevan"), # UTC+04:00 (DST: UTC+05:00)
("Astrakhan", "Europe/Astrakhan"), # UTC+04:00
("Tbilisi", "Asia/Tbilisi"), # UTC+04:00
("Mauritius", "Indian/Mauritius"), # UTC+04:00
("Islamabad", "Asia/Karachi"), # UTC+05:00
("Karachi", "Asia/Karachi"), # UTC+05:00
("Tashkent", "Asia/Tashkent"), # UTC+05:00
("Yekaterinburg", "Asia/Yekaterinburg"), # UTC+05:00
("Maldives", "Indian/Maldives"), # UTC+05:00
("Chagos", "Indian/Chagos"), # UTC+05:00
("Chennai", "Asia/Kolkata"), # UTC+05:30
("Kolkata", "Asia/Kolkata"), # UTC+05:30
("Mumbai", "Asia/Kolkata"), # UTC+05:30
("New Delhi", "Asia/Kolkata"), # UTC+05:30
("Sri Jayawardenepura", "Asia/Colombo"), # UTC+05:30
("Kathmandu", "Asia/Kathmandu"), # UTC+05:45
("Dhaka", "Asia/Dhaka"), # UTC+06:00
("Almaty", "Asia/Almaty"), # UTC+06:00
("Bishkek", "Asia/Bishkek"), # UTC+06:00
("Thimphu", "Asia/Thimphu"), # UTC+06:00
("Yangon (Rangoon)", "Asia/Yangon"), # UTC+06:30
("Cocos Islands", "Indian/Cocos"), # UTC+06:30
("Bangkok", "Asia/Bangkok"), # UTC+07:00
("Hanoi", "Asia/Ho_Chi_Minh"), # UTC+07:00
("Jakarta", "Asia/Jakarta"), # UTC+07:00
("Novosibirsk", "Asia/Novosibirsk"), # UTC+07:00
("Krasnoyarsk", "Asia/Krasnoyarsk"), # UTC+07:00
("Beijing", "Asia/Shanghai"), # UTC+08:00
("Singapore", "Asia/Singapore"), # UTC+08:00
("Perth", "Australia/Perth"), # UTC+08: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
('Midway Island', 'Pacific/Midway'), # UTC-11:00
('American Samoa', 'Pacific/Pago_Pago'), # UTC-11:00
('Hawaii', 'Pacific/Honolulu'), # UTC-10:00
('Aleutian Islands', 'America/Adak'), # UTC-10:00 (DST: UTC-09:00)
('Marquesas Islands', 'Pacific/Marquesas'), # UTC-09:30
('Alaska', 'America/Anchorage'), # UTC-09:00 (DST: UTC-08: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)
('Mountain Time (US and Canada)', 'America/Denver'), # UTC-07:00 (DST: UTC-06:00)
('Arizona', 'America/Phoenix'), # UTC-07:00
('Chihuahua, Mazatlan', 'America/Chihuahua'), # UTC-07:00 (DST: UTC-06:00)
('Central Time (US and Canada)', 'America/Chicago'), # UTC-06:00 (DST: UTC-05:00)
('Saskatchewan', 'America/Regina'), # UTC-06:00
('Guadalajara, Mexico City, Monterrey', 'America/Mexico_City'), # UTC-06:00 (DST: UTC-05:00)
('Tegucigalpa, Honduras', 'America/Tegucigalpa'), # UTC-06:00
('Costa Rica', 'America/Costa_Rica'), # UTC-06:00
('Eastern Time (US and Canada)', 'America/New_York'), # UTC-05:00 (DST: UTC-04:00)
('Lima', 'America/Lima'), # UTC-05:00
('Bogota', 'America/Bogota'), # UTC-05:00
('Quito', 'America/Guayaquil'), # UTC-05:00
('Chetumal', 'America/Cancun'), # UTC-05:00 (DST: UTC-04:00)
('Caracas (Old Venezuela Time)', 'America/Caracas'), # UTC-04:30
('Atlantic Time (Canada)', 'America/Halifax'), # UTC-04:00 (DST: UTC-03:00)
('Caracas', 'America/Caracas'), # UTC-04:00
('Santiago', 'America/Santiago'), # UTC-04:00 (DST: UTC-03:00)
('La Paz', 'America/La_Paz'), # UTC-04:00
('Manaus', 'America/Manaus'), # UTC-04:00
('Georgetown', 'America/Guyana'), # UTC-04:00
('Bermuda', 'Atlantic/Bermuda'), # UTC-04:00 (DST: UTC-03:00)
('Newfoundland Time (Canada)', 'America/St_Johns'), # UTC-03:30 (DST: UTC-02:30)
('Buenos Aires', 'America/Argentina/Buenos_Aires'), # UTC-03:00
('Brasilia', 'America/Sao_Paulo'), # UTC-03:00
('Greenland', 'America/Godthab'), # UTC-03:00 (DST: UTC-02:00)
('Montevideo', 'America/Montevideo'), # UTC-03:00
('Falkland Islands', 'Atlantic/Stanley'), # UTC-03:00
('South Georgia and the South Sandwich Islands', 'Atlantic/South_Georgia'), # UTC-02:00
('Azores', 'Atlantic/Azores'), # UTC-01:00 (DST: UTC+00:00)
('Cape Verde Islands', 'Atlantic/Cape_Verde'), # UTC-01:00
('Dublin', 'Europe/Dublin'), # UTC+00:00 (DST: UTC+01:00)
('Reykjavik', 'Atlantic/Reykjavik'), # UTC+00:00
('Lisbon', 'Europe/Lisbon'), # UTC+00:00 (DST: UTC+01:00)
('Monrovia', 'Africa/Monrovia'), # UTC+00:00
('Casablanca', 'Africa/Casablanca'), # UTC+00:00 (DST: UTC+01:00)
('Central European Time (Berlin, Rome, Paris)', 'Europe/Paris'), # UTC+01:00 (DST: UTC+02:00)
('West Central Africa', 'Africa/Lagos'), # UTC+01:00
('Algiers', 'Africa/Algiers'), # UTC+01:00
('Lagos', 'Africa/Lagos'), # UTC+01:00
('Tunis', 'Africa/Tunis'), # UTC+01:00
('Eastern European Time (Cairo, Helsinki, Kyiv)', 'Europe/Kiev'), # UTC+02:00 (DST: UTC+03:00)
('Athens', 'Europe/Athens'), # UTC+02:00 (DST: UTC+03:00)
('Jerusalem', 'Asia/Jerusalem'), # UTC+02:00 (DST: UTC+03:00)
('Johannesburg', 'Africa/Johannesburg'), # UTC+02:00
('Harare, Pretoria', 'Africa/Harare'), # UTC+02:00
('Moscow Time', 'Europe/Moscow'), # UTC+03:00
('Baghdad', 'Asia/Baghdad'), # UTC+03:00
('Nairobi', 'Africa/Nairobi'), # UTC+03:00
('Kuwait, Riyadh', 'Asia/Riyadh'), # UTC+03:00
('Tehran', 'Asia/Tehran'), # UTC+03:30 (DST: UTC+04:30)
('Abu Dhabi', 'Asia/Dubai'), # UTC+04:00
('Baku', 'Asia/Baku'), # UTC+04:00 (DST: UTC+05:00)
('Yerevan', 'Asia/Yerevan'), # UTC+04:00 (DST: UTC+05:00)
('Astrakhan', 'Europe/Astrakhan'), # UTC+04:00
('Tbilisi', 'Asia/Tbilisi'), # UTC+04:00
('Mauritius', 'Indian/Mauritius'), # UTC+04:00
('Islamabad', 'Asia/Karachi'), # UTC+05:00
('Karachi', 'Asia/Karachi'), # UTC+05:00
('Tashkent', 'Asia/Tashkent'), # UTC+05:00
('Yekaterinburg', 'Asia/Yekaterinburg'), # UTC+05:00
('Maldives', 'Indian/Maldives'), # UTC+05:00
('Chagos', 'Indian/Chagos'), # UTC+05:00
('Chennai', 'Asia/Kolkata'), # UTC+05:30
('Kolkata', 'Asia/Kolkata'), # UTC+05:30
('Mumbai', 'Asia/Kolkata'), # UTC+05:30
('New Delhi', 'Asia/Kolkata'), # UTC+05:30
('Sri Jayawardenepura', 'Asia/Colombo'), # UTC+05:30
('Kathmandu', 'Asia/Kathmandu'), # UTC+05:45
('Dhaka', 'Asia/Dhaka'), # UTC+06:00
('Almaty', 'Asia/Almaty'), # UTC+06:00
('Bishkek', 'Asia/Bishkek'), # UTC+06:00
('Thimphu', 'Asia/Thimphu'), # UTC+06:00
('Yangon (Rangoon)', 'Asia/Yangon'), # UTC+06:30
('Cocos Islands', 'Indian/Cocos'), # UTC+06:30
('Bangkok', 'Asia/Bangkok'), # UTC+07:00
('Hanoi', 'Asia/Ho_Chi_Minh'), # UTC+07:00
('Jakarta', 'Asia/Jakarta'), # UTC+07:00
('Novosibirsk', 'Asia/Novosibirsk'), # UTC+07:00
('Krasnoyarsk', 'Asia/Krasnoyarsk'), # UTC+07:00
('Beijing', 'Asia/Shanghai'), # UTC+08:00
('Singapore', 'Asia/Singapore'), # UTC+08:00
('Perth', 'Australia/Perth'), # UTC+08: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 = []
@@ -177,6 +150,7 @@ class TimezoneEndpoint(APIView):
# Process timezone mapping
for friendly_name, tz_identifier in timezone_locations:
try:
tz = pytz.timezone(tz_identifier)
current_offset = now.astimezone(tz).strftime("%z")
+2 -22
View File
@@ -3,7 +3,6 @@ import csv
import io
import os
from datetime import date
import uuid
from dateutil.relativedelta import relativedelta
from django.db import IntegrityError
@@ -36,7 +35,6 @@ from plane.db.models import (
Workspace,
WorkspaceMember,
WorkspaceTheme,
Profile,
)
from plane.app.permissions import ROLE, allow_permission
from django.utils.decorators import method_decorator
@@ -44,8 +42,6 @@ from django.views.decorators.cache import cache_control
from django.views.decorators.vary import vary_on_cookie
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
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):
@@ -112,12 +108,6 @@ class WorkSpaceViewSet(BaseViewSet):
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):
serializer.save(owner=request.user)
# Create Workspace member
@@ -136,8 +126,6 @@ class WorkSpaceViewSet(BaseViewSet):
data["total_members"] = total_members
data["role"] = 20
workspace_seed.delay(serializer.data["id"])
return Response(data, status=status.HTTP_201_CREATED)
return Response(
[serializer.errors[error][0] for error in serializer.errors],
@@ -159,18 +147,8 @@ class WorkSpaceViewSet(BaseViewSet):
def partial_update(self, 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")
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)
@@ -178,6 +156,8 @@ class UserWorkSpacesEndpoint(BaseAPIView):
search_fields = ["name"]
filterset_fields = ["owner"]
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def get(self, request):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
member_count = (
@@ -12,7 +12,6 @@ from plane.app.permissions import WorkspaceViewerPermission
from plane.app.serializers.cycle import CycleSerializer
from plane.utils.timezone_converter import user_timezone_converter
class WorkspaceCyclesEndpoint(BaseAPIView):
permission_classes = [WorkspaceViewerPermission]
@@ -30,7 +29,6 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -38,7 +38,6 @@ from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.utils.host import base_host
class WorkspaceDraftIssueViewSet(BaseViewSet):
model = DraftIssue
@@ -1,6 +1,5 @@
# Django imports
from django.db.models import Count, Q, OuterRef, Subquery, IntegerField
from django.utils import timezone
from django.db.models.functions import Coalesce
# Third party modules
@@ -134,7 +133,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False, updated_at=timezone.now())
).update(is_active=False)
workspace_member.is_active = False
workspace_member.save()
@@ -195,7 +194,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# # Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False, updated_at=timezone.now())
).update(is_active=False)
# # Deactivate the user
workspace_member.is_active = False
@@ -8,7 +8,6 @@ from plane.app.views.base import BaseAPIView
from plane.db.models import State
from plane.app.permissions import WorkspaceEntityPermission
from plane.utils.cache import cache_response
from collections import defaultdict
class WorkspaceStatesEndpoint(BaseAPIView):
@@ -23,16 +22,5 @@ class WorkspaceStatesEndpoint(BaseAPIView):
project__archived_at__isnull=True,
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
return Response(serializer, status=status.HTTP_200_OK)
@@ -27,7 +27,10 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
create_preference_keys = []
keys = [key for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices]
keys = [
key
for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices
]
for preference in keys:
if preference not in get_preference.values_list("key", flat=True):
@@ -36,10 +39,7 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
preference = WorkspaceUserPreference.objects.bulk_create(
[
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)
],
@@ -47,13 +47,10 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
ignore_conflicts=True,
)
preferences = (
WorkspaceUserPreference.objects.filter(
user=request.user, workspace_id=workspace.id
)
.order_by("sort_order")
.values("key", "is_pinned", "sort_order")
)
preferences = WorkspaceUserPreference.objects.filter(
user=request.user, workspace_id=workspace.id
).order_by("sort_order").values("key", "is_pinned", "sort_order")
user_preferences = {}
@@ -61,7 +58,7 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
user_preferences[(str(preference["key"]))] = {
"is_pinned": preference["is_pinned"],
"sort_order": preference["sort_order"],
}
}
return Response(
user_preferences,
status=status.HTTP_200_OK,
@@ -18,7 +18,6 @@ from plane.bgtasks.user_activation_email_task import user_activation_email
from plane.utils.host import base_host
from plane.utils.ip_address import get_client_ip
class Adapter:
"""Common interface for all auth providers"""
@@ -41,6 +41,7 @@ AUTHENTICATION_ERROR_CODES = {
"GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
"GITHUB_OAUTH_PROVIDER_ERROR": 5120,
"GITLAB_OAUTH_PROVIDER_ERROR": 5121,
# Reset Password
"INVALID_PASSWORD_TOKEN": 5125,
"EXPIRED_PASSWORD_TOKEN": 5130,
@@ -25,24 +25,23 @@ class GitHubOAuthProvider(OauthAdapter):
organization_scope = "read:org"
def __init__(self, request, code=None, state=None, callback=None):
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_SECRET",
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
},
{
"key": "GITHUB_ORGANIZATION_ID",
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
},
]
)
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_SECRET",
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
},
{
"key": "GITHUB_ORGANIZATION_ID",
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
},
]
)
if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET):
@@ -129,10 +128,7 @@ class GitHubOAuthProvider(OauthAdapter):
def is_user_in_organization(self, github_username):
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
def set_user_data(self):
@@ -149,6 +145,7 @@ class GitHubOAuthProvider(OauthAdapter):
error_message="GITHUB_USER_NOT_IN_ORG",
)
email = self.__get_email(headers=headers)
super().set_user_data(
{
+15 -15
View File
@@ -42,11 +42,11 @@ urlpatterns = [
# credentials
path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"),
path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"),
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"),
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"),
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="sign-in"),
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="sign-in"),
# signout
path("sign-out/", SignOutAuthEndpoint.as_view(), name="sign-out"),
path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="space-sign-out"),
path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="sign-out"),
# csrf token
path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"),
# Magic sign in
@@ -56,17 +56,17 @@ urlpatterns = [
path(
"spaces/magic-generate/",
MagicGenerateSpaceEndpoint.as_view(),
name="space-magic-generate",
name="magic-generate",
),
path(
"spaces/magic-sign-in/",
MagicSignInSpaceEndpoint.as_view(),
name="space-magic-sign-in",
name="magic-sign-in",
),
path(
"spaces/magic-sign-up/",
MagicSignUpSpaceEndpoint.as_view(),
name="space-magic-sign-up",
name="magic-sign-up",
),
## Google Oauth
path("google/", GoogleOauthInitiateEndpoint.as_view(), name="google-initiate"),
@@ -74,12 +74,12 @@ urlpatterns = [
path(
"spaces/google/",
GoogleOauthInitiateSpaceEndpoint.as_view(),
name="space-google-initiate",
name="google-initiate",
),
path(
"spaces/google/callback/",
"google/callback/",
GoogleCallbackSpaceEndpoint.as_view(),
name="space-google-callback",
name="google-callback",
),
## Github Oauth
path("github/", GitHubOauthInitiateEndpoint.as_view(), name="github-initiate"),
@@ -87,12 +87,12 @@ urlpatterns = [
path(
"spaces/github/",
GitHubOauthInitiateSpaceEndpoint.as_view(),
name="space-github-initiate",
name="github-initiate",
),
path(
"spaces/github/callback/",
GitHubCallbackSpaceEndpoint.as_view(),
name="space-github-callback",
name="github-callback",
),
## Gitlab Oauth
path("gitlab/", GitLabOauthInitiateEndpoint.as_view(), name="gitlab-initiate"),
@@ -100,12 +100,12 @@ urlpatterns = [
path(
"spaces/gitlab/",
GitLabOauthInitiateSpaceEndpoint.as_view(),
name="space-gitlab-initiate",
name="gitlab-initiate",
),
path(
"spaces/gitlab/callback/",
GitLabCallbackSpaceEndpoint.as_view(),
name="space-gitlab-callback",
name="gitlab-callback",
),
# Email Check
path("email-check/", EmailCheckEndpoint.as_view(), name="email-check"),
@@ -120,12 +120,12 @@ urlpatterns = [
path(
"spaces/forgot-password/",
ForgotPasswordSpaceEndpoint.as_view(),
name="space-forgot-password",
name="forgot-password",
),
path(
"spaces/reset-password/<uidb64>/<token>/",
ResetPasswordSpaceEndpoint.as_view(),
name="space-forgot-password",
name="forgot-password",
),
path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"),
path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"),
+7 -30
View File
@@ -1,53 +1,30 @@
# Django imports
from django.conf import settings
from django.http import HttpRequest
# Third party imports
from rest_framework.request import Request
# Module imports
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"""
# Calculate the base origin from request
base_origin = settings.WEB_URL or settings.APP_BASE_URL
# Admin redirection
# Admin redirections
if is_admin:
admin_base_path = getattr(settings, "ADMIN_BASE_PATH", None)
if not isinstance(admin_base_path, str):
admin_base_path = "/god-mode/"
if not admin_base_path.startswith("/"):
admin_base_path = "/" + admin_base_path
if not admin_base_path.endswith("/"):
admin_base_path += "/"
if settings.ADMIN_BASE_URL:
return settings.ADMIN_BASE_URL + admin_base_path
return settings.ADMIN_BASE_URL
else:
return base_origin + admin_base_path
return base_origin + "/god-mode/"
# Space redirection
# Space redirections
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
return settings.SPACE_BASE_URL
else:
return base_origin + space_base_path
return base_origin + "/spaces/"
# App Redirection
if is_app:
@@ -6,7 +6,6 @@ from django.conf import settings
from plane.utils.host import base_host
from plane.utils.ip_address import get_client_ip
def user_login(request, user, is_app=False, is_admin=False, is_space=False):
login(request=request, user=user)
@@ -21,7 +21,6 @@ from plane.authentication.adapter.error import (
)
from plane.utils.path_validator import validate_next_path
class SignInAuthEndpoint(View):
def post(self, request):
next_path = request.POST.get("next_path")
@@ -18,7 +18,6 @@ from plane.authentication.adapter.error import (
)
from plane.utils.path_validator import validate_next_path
class GitHubOauthInitiateEndpoint(View):
def get(self, request):
# Get host and next path
@@ -18,7 +18,6 @@ from plane.authentication.adapter.error import (
)
from plane.utils.path_validator import validate_next_path
class GitLabOauthInitiateEndpoint(View):
def get(self, request):
# Get host and next path
@@ -20,7 +20,6 @@ from plane.authentication.adapter.error import (
)
from plane.utils.path_validator import validate_next_path
class GoogleOauthInitiateEndpoint(View):
def get(self, request):
request.session["host"] = base_host(request=request, is_app=True)
@@ -96,9 +95,7 @@ class GoogleCallbackEndpoint(View):
# Get the redirection path
path = get_redirection_path(user=user)
# 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)
except AuthenticationException as e:
params = e.get_error_dict()
@@ -53,14 +53,12 @@ class ChangePasswordEndpoint(APIView):
error_message="MISSING_PASSWORD",
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
new_password = request.data.get("new_password", False)
if not new_password:
if not new_password:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"],
error_message="MISSING_PASSWORD",
@@ -68,6 +66,7 @@ class ChangePasswordEndpoint(APIView):
)
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 not user.is_password_autoset and not user.check_password(old_password):
exc = AuthenticationException(
@@ -25,7 +25,6 @@ from plane.authentication.adapter.error import (
)
from plane.utils.path_validator import validate_next_path
class MagicGenerateSpaceEndpoint(APIView):
permission_classes = [AllowAny]
@@ -39,6 +38,7 @@ class MagicGenerateSpaceEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
email = request.data.get("email", "").strip().lower()
try:
validate_email(email)
@@ -459,37 +459,8 @@ def analytic_export_task(email, data, slug):
csv_buffer = generate_csv_from_rows(rows)
send_export_email(email, slug, csv_buffer, rows)
logging.getLogger("plane.worker").info("Email sent successfully.")
logging.getLogger("plane").info("Email sent succesfully.")
return
except Exception as e:
log_exception(e)
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
+5 -9
View File
@@ -12,7 +12,6 @@ from plane.db.models import FileAsset, Page, Issue
from plane.utils.exception_logger import log_exception
from plane.settings.storage import S3Storage
from celery import shared_task
from plane.utils.url import normalize_url_path
def get_entity_id_field(entity_type, entity_id):
@@ -68,14 +67,11 @@ def sync_with_external_service(entity_name, description_html):
"description_html": description_html,
"variant": "rich" if entity_name == "PAGE" else "document",
}
live_url = settings.LIVE_URL
if not live_url:
return {}
url = normalize_url_path(f"{live_url}/convert-document/")
response = requests.post(url, json=data, headers=None)
response = requests.post(
f"{settings.LIVE_BASE_URL}/convert-document/",
json=data,
headers=None,
)
if response.status_code == 200:
return response.json()
except requests.RequestException as e:
+1 -2
View File
@@ -33,7 +33,6 @@ from plane.db.models import (
Intake,
IntakeIssue,
)
from plane.db.models.intake import SourceType
def create_project(workspace, user_id):
@@ -389,7 +388,7 @@ def create_intake_issues(workspace, project, user_id, intake_issue_count):
if status == 0
else None
),
source=SourceType.IN_APP,
source="in-app",
workspace=workspace,
project=project,
)
@@ -284,7 +284,6 @@ def send_email_notification(
"project": str(issue.project.name),
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
"entity_type": "issue",
}
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
@@ -310,7 +309,7 @@ def send_email_notification(
)
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane.worker").info("Email Sent Successfully")
logging.getLogger("plane").info("Email Sent Successfully")
# Update the logs
EmailNotificationLog.objects.filter(
@@ -326,7 +325,7 @@ def send_email_notification(
release_lock(lock_id=lock_id)
return
else:
logging.getLogger("plane.worker").info("Duplicate email received skipping")
logging.getLogger("plane").info("Duplicate email received skipping")
return
except (Issue.DoesNotExist, User.DoesNotExist):
release_lock(lock_id=lock_id)
+107 -274
View File
@@ -3,49 +3,34 @@ import csv
import io
import json
import zipfile
from typing import List
import boto3
from botocore.client import Config
from uuid import UUID
from datetime import datetime, date
# Third party imports
from celery import shared_task
# Django imports
from django.conf import settings
from django.utils import timezone
from openpyxl import Workbook
from django.db.models import F, Prefetch
from collections import defaultdict
# Module imports
from plane.db.models import ExporterHistory, Issue, FileAsset, Label, User, IssueComment
from plane.db.models import ExporterHistory, Issue
from plane.utils.exception_logger import log_exception
def dateTimeConverter(time: datetime) -> str | None:
"""
Convert a datetime object to a formatted string.
"""
def dateTimeConverter(time):
if time:
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
def dateConverter(time: date) -> str | None:
"""
Convert a date object to a formatted string.
"""
def dateConverter(time):
if time:
return time.strftime("%a, %d %b %Y")
def create_csv_file(data: List[List[str]]) -> str:
"""
Create a CSV file from the provided data.
"""
def create_csv_file(data):
csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
@@ -56,17 +41,11 @@ def create_csv_file(data: List[List[str]]) -> str:
return csv_buffer.getvalue()
def create_json_file(data: List[dict]) -> str:
"""
Create a JSON file from the provided data.
"""
def create_json_file(data):
return json.dumps(data)
def create_xlsx_file(data: List[List[str]]) -> bytes:
"""
Create an XLSX file from the provided data.
"""
def create_xlsx_file(data):
workbook = Workbook()
sheet = workbook.active
@@ -79,10 +58,7 @@ def create_xlsx_file(data: List[List[str]]) -> bytes:
return xlsx_buffer.getvalue()
def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO:
"""
Create a ZIP file from the provided files.
"""
def create_zip_file(files):
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
for filename, file_content in files:
@@ -92,13 +68,7 @@ def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO:
return zip_buffer
# 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.
"""
def upload_to_s3(zip_file, workspace_id, token_id, slug):
file_name = (
f"{workspace_id}/export-{slug}-{token_id[:6]}-{str(timezone.now().date())}.zip"
)
@@ -180,85 +150,75 @@ def upload_to_s3(
exporter_instance.save(update_fields=["status", "url", "key"])
def generate_table_row(issue: dict) -> List[str]:
"""
Generate a table row from an issue dictionary.
"""
def generate_table_row(issue):
return [
f"""{issue["project_identifier"]}-{issue["sequence_id"]}""",
issue["project_name"],
f"""{issue["project__identifier"]}-{issue["sequence_id"]}""",
issue["project__name"],
issue["name"],
issue["description"],
issue["state_name"],
issue["description_stripped"],
issue["state__name"],
dateConverter(issue["start_date"]),
dateConverter(issue["target_date"]),
issue["priority"],
issue["created_by"],
", ".join(issue["labels"]) if issue["labels"] else "",
issue["cycle_name"],
issue["cycle_start_date"],
issue["cycle_end_date"],
", ".join(issue.get("module_name", "")) if issue.get("module_name") else "",
(
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_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["updated_at"]),
dateTimeConverter(issue["completed_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: dict) -> dict:
"""
Generate a JSON row from an issue dictionary.
"""
def generate_json_row(issue):
return {
"ID": f"""{issue["project_identifier"]}-{issue["sequence_id"]}""",
"Project": issue["project_name"],
"ID": f"""{issue["project__identifier"]}-{issue["sequence_id"]}""",
"Project": issue["project__name"],
"Name": issue["name"],
"Description": issue["description"],
"State": issue["state_name"],
"Description": issue["description_stripped"],
"State": issue["state__name"],
"Start Date": dateConverter(issue["start_date"]),
"Target Date": dateConverter(issue["target_date"]),
"Priority": issue["priority"],
"Created By": (f"{issue['created_by']}" if issue["created_by"] else ""),
"Assignee": issue["assignees"],
"Labels": issue["labels"],
"Cycle Name": issue["cycle_name"],
"Cycle Start Date": issue["cycle_start_date"],
"Cycle End Date": issue["cycle_end_date"],
"Module Name": issue["module_name"],
"Created By": (
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
else ""
),
"Assignee": (
f"{issue['assignees__first_name']} {issue['assignees__last_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"]),
"Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_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: List[dict], row: dict) -> None:
"""
Update the json row with the new assignee and label.
"""
def update_json_row(rows, row):
matched_index = next(
(
index
@@ -287,10 +247,7 @@ def update_json_row(rows: List[dict], row: dict) -> None:
rows.append(row)
def update_table_row(rows: List[List[str]], row: List[str]) -> None:
"""
Update the table row with the new assignee and label.
"""
def update_table_row(rows, row):
matched_index = next(
(index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]),
None,
@@ -312,12 +269,7 @@ def update_table_row(rows: List[List[str]], row: List[str]) -> None:
rows.append(row)
def generate_csv(
header: List[str],
project_id: str,
issues: List[dict],
files: List[tuple[str, str | bytes]],
) -> None:
def generate_csv(header, project_id, issues, files):
"""
Generate CSV export for all the passed issues.
"""
@@ -329,15 +281,7 @@ def generate_csv(
files.append((f"{project_id}.csv", csv_file))
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.
"""
def generate_json(header, project_id, issues, files):
rows = []
for issue in issues:
row = generate_json_row(issue)
@@ -346,169 +290,68 @@ def generate_json(
files.append((f"{project_id}.json", json_file))
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.
"""
def generate_xlsx(header, project_id, issues, files):
rows = [header]
for issue in issues:
row = generate_table_row(issue)
update_table_row(rows, row)
xlsx_file = create_xlsx_file(rows)
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
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.
"""
def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug):
try:
exporter_instance = ExporterHistory.objects.get(token=token_id)
exporter_instance.status = "processing"
exporter_instance.save(update_fields=["status"])
# Base query to get the issues
workspace_issues = (
Issue.objects.filter(
workspace__id=workspace_id,
project_id__in=project_ids,
project__project_projectmember__member=exporter_instance.initiated_by_id,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.select_related(
"project",
"workspace",
"state",
"parent",
"created_by",
"estimate_point",
)
.prefetch_related(
"labels",
"issue_cycle__cycle",
"issue_module__module",
"issue_comments",
"assignees",
Prefetch(
"assignees",
queryset=User.objects.only("first_name", "last_name").distinct(),
to_attr="assignee_details",
),
Prefetch(
"labels",
queryset=Label.objects.only("name").distinct(),
to_attr="label_details",
),
"issue_subscribers",
"issue_link",
(
Issue.objects.filter(
workspace__id=workspace_id,
project_id__in=project_ids,
project__project_projectmember__member=exporter_instance.initiated_by_id,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.select_related("project", "workspace", "state", "parent", "created_by")
.prefetch_related(
"assignees", "labels", "issue_cycle__cycle", "issue_module__module"
)
.values(
"id",
"project__identifier",
"project__name",
"project__id",
"sequence_id",
"name",
"description_stripped",
"priority",
"start_date",
"target_date",
"state__name",
"created_at",
"updated_at",
"completed_at",
"archived_at",
"issue_cycle__cycle__name",
"issue_cycle__cycle__start_date",
"issue_cycle__cycle__end_date",
"issue_module__module__name",
"issue_module__module__start_date",
"issue_module__module__target_date",
"created_by__first_name",
"created_by__last_name",
"assignees__first_name",
"assignees__last_name",
"labels__name",
)
)
.order_by("project__identifier", "sequence_id")
.distinct()
)
# Get the attachments for the issues
file_assets = FileAsset.objects.filter(
issue_id__in=workspace_issues.values_list("id", flat=True),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
).annotate(work_item_id=F("issue_id"), asset_id=F("id"))
# Create a dictionary to store the attachments for the issues
attachment_dict = defaultdict(list)
for asset in file_assets:
attachment_dict[asset.work_item_id].append(asset.asset_id)
# Create a list to store the issues data
issues_data = []
# Iterate over the issues
for issue in workspace_issues:
attachments = attachment_dict.get(issue.id, [])
issue_data = {
"id": issue.id,
"project_identifier": issue.project.identifier,
"project_name": issue.project.name,
"project_id": issue.project.id,
"sequence_id": issue.sequence_id,
"name": issue.name,
"description": issue.description_stripped,
"priority": issue.priority,
"start_date": issue.start_date,
"target_date": issue.target_date,
"state_name": issue.state.name if issue.state else None,
"created_at": issue.created_at,
"updated_at": issue.updated_at,
"completed_at": issue.completed_at,
"archived_at": issue.archived_at,
"module_name": [
module.module.name for module in issue.issue_module.all()
],
"created_by": get_created_by(issue),
"labels": [label.name for label in issue.label_details],
"comments": [
{
"comment": comment.comment_stripped,
"created_at": dateConverter(comment.created_at),
"created_by": get_created_by(comment),
}
for comment in issue.issue_comments.all()
],
"estimate": issue.estimate_point.value
if issue.estimate_point and issue.estimate_point.value
else "",
"link": [link.url for link in issue.issue_link.all()],
"assignees": [
f"{assignee.first_name} {assignee.last_name}"
for assignee in issue.assignee_details
],
"subscribers_count": issue.issue_subscribers.count(),
"attachment_count": len(attachments),
"attachment_links": [
f"/api/assets/v2/workspaces/{issue.workspace.slug}/projects/{issue.project_id}/issues/{issue.id}/attachments/{asset}/"
for asset in attachments
],
}
# Get Cycles data for the issue
cycle = issue.issue_cycle.last()
if cycle:
# Update cycle data
issue_data["cycle_name"] = cycle.cycle.name
issue_data["cycle_start_date"] = dateConverter(cycle.cycle.start_date)
issue_data["cycle_end_date"] = dateConverter(cycle.cycle.end_date)
else:
issue_data["cycle_name"] = ""
issue_data["cycle_start_date"] = ""
issue_data["cycle_end_date"] = ""
issues_data.append(issue_data)
# CSV header
# CSV header
header = [
"ID",
"Project",
@@ -519,25 +362,20 @@ def issue_export_task(
"Target Date",
"Priority",
"Created By",
"Assignee",
"Labels",
"Cycle Name",
"Cycle Start Date",
"Cycle End Date",
"Module Name",
"Module Start Date",
"Module Target Date",
"Created At",
"Updated At",
"Completed At",
"Archived At",
"Comments",
"Estimate",
"Link",
"Assignees",
"Subscribers Count",
"Attachment Count",
"Attachment Links",
]
# Map the provider to the function
EXPORTER_MAPPER = {
"csv": generate_csv,
"json": generate_json,
@@ -546,13 +384,8 @@ def issue_export_task(
files = []
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:
issues = project_dict.get(str(project_id), [])
issues = workspace_issues.filter(project__id=project_id)
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(header, project_id, issues, files)
@@ -560,7 +393,7 @@ def issue_export_task(
else:
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(header, workspace_id, issues_data, files)
exporter(header, workspace_id, workspace_issues, files)
zip_buffer = create_zip_file(files)
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.send()
logging.getLogger("plane.worker").info("Email sent successfully")
logging.getLogger("plane").info("Email sent successfully")
return
except Exception as e:
log_exception(e)
@@ -482,7 +482,7 @@ def track_estimate_points(
),
old_value=old_estimate.value if old_estimate else None,
new_value=new_estimate.value if new_estimate else None,
field="estimate_" + new_estimate.estimate.type,
field="estimate_point",
project_id=project_id,
workspace_id=workspace_id,
comment="updated the estimate point to ",
@@ -1650,6 +1650,40 @@ def issue_activity(
# Save all the values to database
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:
notifications.delay(
@@ -53,7 +53,7 @@ def magic_link(email, key, token):
)
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane.worker").info("Email sent successfully.")
logging.getLogger("plane").info("Email sent successfully.")
return
except Exception as e:
log_exception(e)
@@ -80,7 +80,7 @@ def project_add_user_email(current_site, project_member_id, invitor_id):
# Send the email
msg.send()
# Log the success
logging.getLogger("plane.worker").info("Email sent successfully.")
logging.getLogger("plane").info("Email sent successfully.")
return
except Exception as 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.send()
logging.getLogger("plane.worker").info("Email sent successfully.")
logging.getLogger("plane").info("Email sent successfully.")
return
except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist):
return
@@ -58,7 +58,7 @@ def user_activation_email(current_site, user_id):
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane.worker").info("Email sent successfully.")
logging.getLogger("plane").info("Email sent successfully.")
return
except Exception as e:
log_exception(e)
@@ -60,7 +60,7 @@ def user_deactivation_email(current_site, user_id):
# Attach HTML content
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane.worker").info("Email sent successfully.")
logging.getLogger("plane").info("Email sent successfully.")
return
except Exception as e:
log_exception(e)

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