Compare commits
114
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
248acdc4c1 | ||
|
|
7b41ffa08e | ||
|
|
906c1b51bb | ||
|
|
8e518966c2 | ||
|
|
f8f764622a | ||
|
|
f5fe8d0a7e | ||
|
|
e0c97c5471 | ||
|
|
0370a1bfdd | ||
|
|
5e621cf620 | ||
|
|
f1761c65b5 | ||
|
|
97e21ba21c | ||
|
|
647813a6ab | ||
|
|
76ebf395e6 | ||
|
|
67dfe91890 | ||
|
|
b53016b449 | ||
|
|
e20f686398 | ||
|
|
ff544c98b7 | ||
|
|
be722f708d | ||
|
|
43b3a7730e | ||
|
|
20510bb2dd | ||
|
|
7124e8e7ce | ||
|
|
df1a512a80 | ||
|
|
639a2aab41 | ||
|
|
491b5bc6cc | ||
|
|
368af223db | ||
|
|
8bb7ebb725 | ||
|
|
079a624006 | ||
|
|
0bfb74d4c0 | ||
|
|
362d29c7b0 | ||
|
|
a55253d242 | ||
|
|
69b64680d1 | ||
|
|
2f45bfb7f6 | ||
|
|
4b59998e52 | ||
|
|
f70384bff7 | ||
|
|
7caa1bb482 | ||
|
|
af939fca41 | ||
|
|
a3e651854a | ||
|
|
2240ac0e74 | ||
|
|
11e7bd115b | ||
|
|
0ab94ed6d6 | ||
|
|
a9e9cb2983 | ||
|
|
f0bc2bd3bd | ||
|
|
7659997b53 | ||
|
|
316856a555 | ||
|
|
f41e121e58 | ||
|
|
85daa1572c | ||
|
|
b359849606 | ||
|
|
5ea74e1112 | ||
|
|
d9ea119588 | ||
|
|
d34c7f9a0e | ||
|
|
f81e120328 | ||
|
|
85d90030cf | ||
|
|
82c970ac4b | ||
|
|
392c8cf2e1 | ||
|
|
c3906048ea | ||
|
|
1090b3e938 | ||
|
|
fe867135c4 | ||
|
|
65166d19be | ||
|
|
3bc85c8430 | ||
|
|
cf84ade52c | ||
|
|
9699a01fb8 | ||
|
|
bc96d34cb7 | ||
|
|
739b1bfb75 | ||
|
|
22da26742c | ||
|
|
63f3a57022 | ||
|
|
36d42856e9 | ||
|
|
105ac5ece5 | ||
|
|
b8a41ad5a0 | ||
|
|
7c74d0a403 | ||
|
|
5f7ffcb37a | ||
|
|
584a1aa725 | ||
|
|
1e1520b85f | ||
|
|
0bc45e3047 | ||
|
|
cacd1b489e | ||
|
|
e650b19933 | ||
|
|
ec478a8979 | ||
|
|
e07d91e857 | ||
|
|
9ccb3e321a | ||
|
|
f2c046dff9 | ||
|
|
2a378b3bc1 | ||
|
|
8b0a797906 | ||
|
|
cea6f7530b | ||
|
|
a7e2e596bf | ||
|
|
980428b204 | ||
|
|
f428c3bdaf | ||
|
|
a05cd88a53 | ||
|
|
60220801ac | ||
|
|
8db95d9ec0 | ||
|
|
27bf2575bd | ||
|
|
22bb3c5ecc | ||
|
|
6b85d67f6c | ||
|
|
123f59e74b | ||
|
|
c7bf912cf2 | ||
|
|
2980836015 | ||
|
|
78fbdde165 | ||
|
|
dbc5a6348d | ||
|
|
c685042a47 | ||
|
|
a4de486cf7 | ||
|
|
3c84e75350 | ||
|
|
39749106a2 | ||
|
|
9bcb1fa469 | ||
|
|
c31a225775 | ||
|
|
73c317f283 | ||
|
|
a0da806a79 | ||
|
|
eddf80aaed | ||
|
|
05b1c147a9 | ||
|
|
ae7898aaee | ||
|
|
4806bdf99c | ||
|
|
37c59ef0d1 | ||
|
|
3f11183768 | ||
|
|
d38147b875 | ||
|
|
3436c4f1f5 | ||
|
|
31e8563725 | ||
|
|
5ddfd0e1a9 |
@@ -1,7 +1,7 @@
|
||||
name: Bug report
|
||||
description: Create a bug report to help us improve Plane
|
||||
title: "[bug]: "
|
||||
labels: [🐛bug]
|
||||
labels: [🐛bug, plane]
|
||||
assignees: [vihar, pushya22]
|
||||
body:
|
||||
- type: markdown
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Feature request
|
||||
description: Suggest a feature to improve Plane
|
||||
title: "[feature]: "
|
||||
labels: [✨feature]
|
||||
labels: [✨feature, plane]
|
||||
assignees: [vihar, pushya22]
|
||||
body:
|
||||
- type: markdown
|
||||
|
||||
@@ -27,11 +27,13 @@ jobs:
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.requested_reviewers != null
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.x"
|
||||
python-version: "3.12.x"
|
||||
cache: 'pip'
|
||||
cache-dependency-path: 'apps/api/requirements.txt'
|
||||
- name: Install Pylint
|
||||
run: python -m pip install ruff
|
||||
- name: Install API Dependencies
|
||||
|
||||
@@ -17,10 +17,11 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-lint:
|
||||
name: Build and lint web apps
|
||||
# Format check has no build dependencies - run immediately in parallel
|
||||
check-format:
|
||||
name: check:format
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
timeout-minutes: 10
|
||||
if: |
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.requested_reviewers != null
|
||||
@@ -29,28 +30,140 @@ jobs:
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 50
|
||||
filter: blob:none
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
- name: Enable Corepack and pnpm
|
||||
run: corepack enable pnpm
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-store-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint Affected
|
||||
run: pnpm turbo run check:lint --affected
|
||||
|
||||
- name: Check Affected format
|
||||
- name: Check formatting
|
||||
run: pnpm turbo run check:format --affected
|
||||
|
||||
- name: Check Affected types
|
||||
run: pnpm turbo run check:types --affected
|
||||
# Build packages - required for lint and type checks
|
||||
build:
|
||||
name: Build packages
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
if: |
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.requested_reviewers != null
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 50
|
||||
filter: blob:none
|
||||
|
||||
- name: Build Affected
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
- name: Enable Corepack and pnpm
|
||||
run: corepack enable pnpm
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-store-${{ runner.os }}-
|
||||
|
||||
- name: Restore Turbo cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-
|
||||
turbo-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm turbo run build --affected
|
||||
|
||||
- name: Save Turbo cache
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
|
||||
|
||||
# Lint and type checks depend on build artifacts
|
||||
check:
|
||||
name: ${{ matrix.task }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
task: [check:lint, check:types]
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 50
|
||||
filter: blob:none
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
- name: Enable Corepack and pnpm
|
||||
run: corepack enable pnpm
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-store-${{ runner.os }}-
|
||||
|
||||
- name: Restore Turbo cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: pnpm turbo run ${{ matrix.task }} --affected
|
||||
|
||||
@@ -111,3 +111,4 @@ build/
|
||||
.react-router/
|
||||
AGENTS.md
|
||||
temp/
|
||||
scripts/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pnpm lint-staged
|
||||
@@ -1,34 +1,54 @@
|
||||
# Enforce pnpm workspace behavior and allow Turbo's lifecycle hooks if scripts are disabled
|
||||
# This repo uses pnpm with workspaces.
|
||||
# ------------------------------
|
||||
# Core Workspace Behavior
|
||||
# ------------------------------
|
||||
|
||||
# Prefer linking local workspace packages when available
|
||||
prefer-workspace-packages=true
|
||||
link-workspace-packages=true
|
||||
shared-workspace-lockfile=true
|
||||
# Always prefer using local workspace packages when available
|
||||
prefer-workspace-packages = true
|
||||
|
||||
# Make peer installs smoother across the monorepo
|
||||
auto-install-peers=true
|
||||
strict-peer-dependencies=false
|
||||
# Symlink workspace packages instead of duplicating them
|
||||
link-workspace-packages = true
|
||||
|
||||
# If scripts are disabled (e.g., CI with --ignore-scripts), allowlisted packages can still run their hooks
|
||||
# Turbo occasionally performs postinstall tasks for optimal performance
|
||||
# moved to pnpm-workspace.yaml: onlyBuiltDependencies (e.g., allow turbo)
|
||||
# Use a single lockfile across the whole monorepo
|
||||
shared-workspace-lockfile = true
|
||||
|
||||
public-hoist-pattern[]=*eslint*
|
||||
public-hoist-pattern[]=prettier
|
||||
public-hoist-pattern[]=typescript
|
||||
# Ensure packages added from workspace save using workspace: protocol
|
||||
save-workspace-protocol = true
|
||||
|
||||
# Reproducible installs across CI and dev
|
||||
prefer-frozen-lockfile=true
|
||||
|
||||
# Prefer resolving to highest versions in monorepo to reduce duplication
|
||||
resolution-mode=highest
|
||||
# ------------------------------
|
||||
# Dependency Resolution
|
||||
# ------------------------------
|
||||
|
||||
# Speed up native module builds by caching side effects
|
||||
side-effects-cache=true
|
||||
# Choose the highest compatible version across the workspace
|
||||
# → reduces fragmentation & node_modules bloat
|
||||
resolution-mode = highest
|
||||
|
||||
# Speed up local dev by reusing local store when possible
|
||||
prefer-offline=true
|
||||
# Automatically install peer dependencies instead of forcing every package to declare them
|
||||
auto-install-peers = true
|
||||
|
||||
# Ensure workspace protocol is used when adding internal deps
|
||||
save-workspace-protocol=true
|
||||
# Don't break the install if peers are missing
|
||||
strict-peer-dependencies = false
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# Performance Optimizations
|
||||
# ------------------------------
|
||||
|
||||
# Use cached artifacts for native modules (sharp, esbuild, etc.)
|
||||
side-effects-cache = true
|
||||
|
||||
# Prefer local cached packages rather than hitting network
|
||||
prefer-offline = true
|
||||
|
||||
# In CI, refuse to modify lockfile (prevents drift)
|
||||
prefer-frozen-lockfile = true
|
||||
|
||||
# Use isolated linker (best compatibility with Node ecosystem tools)
|
||||
node-linker = isolated
|
||||
|
||||
# Hoist commonly used tools to the root to prevent duplicates and speed up resolution
|
||||
public-hoist-pattern[] = typescript
|
||||
public-hoist-pattern[] = eslint
|
||||
public-hoist-pattern[] = *@plane/*
|
||||
public-hoist-pattern[] = vite
|
||||
public-hoist-pattern[] = turbo
|
||||
@@ -0,0 +1,10 @@
|
||||
.next/
|
||||
.react-router/
|
||||
.turbo/
|
||||
.vite/
|
||||
build/
|
||||
dist/
|
||||
node_modules/
|
||||
out/
|
||||
pnpm-lock.yaml
|
||||
storybook-static/
|
||||
+11
-2
@@ -1,6 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["packages/codemods/**/*"],
|
||||
"options": {
|
||||
"printWidth": 80
|
||||
}
|
||||
}
|
||||
],
|
||||
"plugins": ["@prettier/plugin-oxc"],
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"plugins": ["@prettier/plugin-oxc"]
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
eslint.config.mjs @lifeiscontent
|
||||
+10
-8
@@ -91,7 +91,7 @@ If you would like to _implement_ it, an issue with your proposal must be submitt
|
||||
To ensure consistency throughout the source code, please keep these rules in mind as you are working:
|
||||
|
||||
- All features or bug fixes must be tested by one or more specs (unit-tests).
|
||||
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
|
||||
- We lint with [ESLint 9](https://eslint.org/docs/latest/) using the shared `eslint.config.mjs` (type-aware via `typescript-eslint`) and format with [Prettier](https://prettier.io/) using `prettier.config.cjs`.
|
||||
|
||||
## Ways to contribute
|
||||
|
||||
@@ -187,18 +187,19 @@ Adding a new language involves several steps to ensure it integrates seamlessly
|
||||
Add the new language to the TLanguage type in the language definitions file:
|
||||
|
||||
```ts
|
||||
// packages/i18n/src/types/language.ts
|
||||
export type TLanguage = "en" | "fr" | "your-lang";
|
||||
// packages/i18n/src/types/language.ts
|
||||
export type TLanguage = "en" | "fr" | "your-lang";
|
||||
```
|
||||
|
||||
1. **Add language configuration**
|
||||
Include the new language in the list of supported languages:
|
||||
|
||||
```ts
|
||||
// packages/i18n/src/constants/language.ts
|
||||
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "Your Language", value: "your-lang" }
|
||||
];
|
||||
// packages/i18n/src/constants/language.ts
|
||||
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "Your Language", value: "your-lang" },
|
||||
];
|
||||
```
|
||||
|
||||
2. **Create translation files**
|
||||
@@ -210,6 +211,7 @@ Adding a new language involves several steps to ensure it integrates seamlessly
|
||||
|
||||
3. **Update import logic**
|
||||
Modify the language import logic to include your new language:
|
||||
|
||||
```ts
|
||||
private importLanguageFile(language: TLanguage): Promise<any> {
|
||||
switch (language) {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
.next/*
|
||||
.react-router/*
|
||||
.vite/*
|
||||
out/*
|
||||
public/*
|
||||
dist/*
|
||||
node_modules/*
|
||||
.turbo/*
|
||||
.env*
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
.env.test
|
||||
@@ -1,18 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/next.js"],
|
||||
ignorePatterns: ["build/**", "dist/**", ".vite/**"],
|
||||
rules: {
|
||||
"import/no-duplicates": ["error", { "prefer-inline": false }],
|
||||
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
||||
"@typescript-eslint/no-import-type-side-effects": "error",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
prefer: "type-imports",
|
||||
fixStyle: "separate-type-imports",
|
||||
disallowTypeAnnotations: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
.next
|
||||
.react-router
|
||||
.vite
|
||||
.vercel
|
||||
.tubro
|
||||
out/
|
||||
dist/
|
||||
.next/
|
||||
.react-router/
|
||||
.turbo/
|
||||
.vite/
|
||||
build/
|
||||
dist/
|
||||
node_modules/
|
||||
out/
|
||||
pnpm-lock.yaml
|
||||
storybook-static/
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"plugins": ["@prettier/plugin-oxc"]
|
||||
}
|
||||
@@ -13,7 +13,7 @@ RUN corepack enable pnpm
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
RUN pnpm add -g turbo@2.5.8
|
||||
RUN pnpm add -g turbo@2.6.3
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -62,10 +62,12 @@ COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
|
||||
# Fetch dependencies to cache store, then install offline with dev deps
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
|
||||
# Copy full directory structure before fetch to ensure all package.json files are available
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
# Fetch dependencies to cache store, then install offline with dev deps
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
|
||||
|
||||
# Build only the admin package
|
||||
@@ -73,7 +75,7 @@ RUN pnpm turbo run build --filter=admin
|
||||
|
||||
# =========================================================================== #
|
||||
|
||||
FROM nginx:1.27-alpine AS production
|
||||
FROM nginx:1.29-alpine AS production
|
||||
|
||||
COPY apps/admin/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=installer /app/apps/admin/build/client /usr/share/nginx/html/god-mode
|
||||
|
||||
@@ -163,7 +163,6 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
|
||||
label={EMAIL_SECURITY_OPTIONS[emailSecurityKey]}
|
||||
onChange={handleEmailSecurityChange}
|
||||
buttonClassName="rounded-md border-custom-border-200"
|
||||
optionsClassName="w-full"
|
||||
input
|
||||
>
|
||||
{Object.entries(EMAIL_SECURITY_OPTIONS).map(([key, value]) => (
|
||||
|
||||
@@ -11,7 +11,7 @@ import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
// assets
|
||||
// eslint-disable-next-line import/order
|
||||
|
||||
import packageJson from "package.json";
|
||||
|
||||
const helpOptions = [
|
||||
|
||||
@@ -177,7 +177,6 @@ export function WorkspaceCreateForm() {
|
||||
}
|
||||
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
|
||||
input
|
||||
optionsClassName="w-full"
|
||||
>
|
||||
{ORGANIZATION_SIZE.map((item) => (
|
||||
<CustomSelect.Option key={item} value={item}>
|
||||
|
||||
@@ -26,7 +26,7 @@ export enum EErrorAlertType {
|
||||
}
|
||||
|
||||
const errorCodeMessages: {
|
||||
[key in EAdminAuthErrorCodes]: { title: string; message: (email?: string | undefined) => React.ReactNode };
|
||||
[key in EAdminAuthErrorCodes]: { title: string; message: (email?: string) => React.ReactNode };
|
||||
} = {
|
||||
// admin
|
||||
[EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: {
|
||||
@@ -79,14 +79,11 @@ const errorCodeMessages: {
|
||||
},
|
||||
[EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED]: {
|
||||
title: `User account deactivated`,
|
||||
message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
|
||||
message: () => `User account deactivated. Please contact ${SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
|
||||
},
|
||||
};
|
||||
|
||||
export const authErrorHandler = (
|
||||
errorCode: EAdminAuthErrorCodes,
|
||||
email?: string | undefined
|
||||
): TAdminAuthErrorInfo | undefined => {
|
||||
export const authErrorHandler = (errorCode: EAdminAuthErrorCodes, email?: string): TAdminAuthErrorInfo | undefined => {
|
||||
const bannerAlertErrorCodes = [
|
||||
EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
|
||||
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/order */
|
||||
import * as Sentry from "@sentry/react-router";
|
||||
import { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
|
||||
@@ -11,6 +11,7 @@ export function UpgradeButton() {
|
||||
href="https://plane.so/pricing?mode=self-hosted"
|
||||
target="_blank"
|
||||
className={cn(getButtonStyling("primary", "sm"))}
|
||||
rel="noreferrer"
|
||||
>
|
||||
Upgrade
|
||||
<SquareArrowOutUpRight className="h-3.5 w-3.5 p-0.5" />
|
||||
|
||||
@@ -24,6 +24,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
||||
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
|
||||
target="_blank"
|
||||
className="group flex items-center justify-between p-4 gap-2.5 truncate border border-custom-border-200/70 hover:border-custom-border-200 hover:bg-custom-background-90 rounded-md"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<span
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Admin UI for Plane",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -11,11 +11,11 @@
|
||||
"preview": "react-router build && serve -s build/client -l 3001",
|
||||
"start": "serve -s build/client -l 3001",
|
||||
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist && rm -rf build",
|
||||
"check:lint": "eslint . --max-warnings 19",
|
||||
"check:lint": "eslint . --cache --cache-location node_modules/.cache/eslint/ --max-warnings=485",
|
||||
"check:types": "react-router typegen && tsc --noEmit",
|
||||
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
|
||||
"fix:lint": "eslint . --fix",
|
||||
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\""
|
||||
"check:format": "prettier . --cache --check",
|
||||
"fix:lint": "eslint . --cache --cache-location node_modules/.cache/eslint/ --fix --max-warnings=485",
|
||||
"fix:format": "prettier . --cache --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bprogress/core": "catalog:",
|
||||
@@ -48,16 +48,14 @@
|
||||
"uuid": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@dotenvx/dotenvx": "catalog:",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@prettier/plugin-oxc": "0.0.4",
|
||||
"@react-router/dev": "catalog:",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"dotenv": "^16.4.5",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
module.exports = require("@plane/tailwind-config/postcss.config.js");
|
||||
|
||||
@@ -296,6 +296,10 @@ body {
|
||||
}
|
||||
|
||||
/* scrollbar style */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@-moz-document url-prefix() {
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
@@ -356,6 +360,23 @@ body {
|
||||
margin-top: 44px;
|
||||
}
|
||||
|
||||
/* scrollbar xs size */
|
||||
.scrollbar-xs::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
}
|
||||
.scrollbar-xs::-webkit-scrollbar-thumb {
|
||||
border: 3px solid rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.shadow-custom {
|
||||
box-shadow: 2px 2px 8px 2px rgba(234, 231, 250, 0.3); /* Convert #EAE7FA4D to rgba */
|
||||
}
|
||||
/* backdrop filter */
|
||||
.backdrop-blur-custom {
|
||||
@apply backdrop-filter blur-[9px];
|
||||
}
|
||||
|
||||
/* scrollbar sm size */
|
||||
.scrollbar-sm::-webkit-scrollbar {
|
||||
height: 12px;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const sharedConfig = require("@plane/tailwind-config/tailwind.config.js");
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
{
|
||||
"extends": "@plane/typescript-config/react-router.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"noImplicitOverride": false,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noUnusedParameters": false,
|
||||
"noUnusedLocals": false,
|
||||
"rootDirs": [".", "./.react-router/types"],
|
||||
"types": ["node", "vite/client"],
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@/app/*": ["app/*"],
|
||||
"@/*": ["core/*"],
|
||||
"@/plane-admin/*": ["ce/*"],
|
||||
"@/styles/*": ["styles/*"]
|
||||
},
|
||||
"strictNullChecks": true
|
||||
"package.json": ["./package.json"],
|
||||
"ce/*": ["./ce/*"],
|
||||
"@/app/*": ["./app/*"],
|
||||
"@/*": ["./core/*"],
|
||||
"@/plane-admin/*": ["./ce/*"],
|
||||
"@/ce/*": ["./ce/*"],
|
||||
"@/styles/*": ["./styles/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
import * as dotenv from "@dotenvx/dotenvx";
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import dotenv from "dotenv";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { joinUrlPath } from "@plane/utils";
|
||||
|
||||
@@ -32,6 +32,9 @@ AWS_S3_ENDPOINT_URL="http://localhost:9000"
|
||||
AWS_S3_BUCKET_NAME="uploads"
|
||||
# Maximum file upload limit
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
# Signed URL expiration time in seconds (default: 3600 = 1 hour)
|
||||
# Set to 30 for 30 seconds, 300 for 5 minutes, etc.
|
||||
SIGNED_URL_EXPIRATION=3600
|
||||
|
||||
# Settings related to Docker
|
||||
DOCKERIZED=1 # deprecated
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.next/
|
||||
.react-router/
|
||||
.turbo/
|
||||
.vite/
|
||||
build/
|
||||
dist/
|
||||
node_modules/
|
||||
out/
|
||||
pnpm-lock.yaml
|
||||
storybook-static/
|
||||
@@ -32,4 +32,7 @@ python manage.py create_bucket
|
||||
# Clear Cache before starting to remove stale values
|
||||
python manage.py clear_cache
|
||||
|
||||
# Collect static files
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"description": "API server powering Plane's backend"
|
||||
|
||||
@@ -54,4 +54,5 @@ from .asset import (
|
||||
FileAssetSerializer,
|
||||
)
|
||||
from .invite import WorkspaceInviteSerializer
|
||||
from .member import ProjectMemberSerializer
|
||||
from .member import ProjectMemberSerializer
|
||||
from .sticky import StickySerializer
|
||||
|
||||
@@ -4,7 +4,7 @@ from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import Cycle, CycleIssue, User
|
||||
from plane.db.models import Cycle, CycleIssue, User, Project
|
||||
from plane.utils.timezone_converter import convert_to_utc
|
||||
|
||||
|
||||
@@ -55,6 +55,18 @@ class CycleCreateSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
project_id = self.initial_data.get("project_id") or (
|
||||
self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None
|
||||
)
|
||||
|
||||
if not project_id:
|
||||
raise serializers.ValidationError("Project ID is required")
|
||||
|
||||
project = Project.objects.filter(id=project_id).first()
|
||||
if not project:
|
||||
raise serializers.ValidationError("Project not found")
|
||||
if not project.cycle_view:
|
||||
raise serializers.ValidationError("Cycles are not enabled for this project")
|
||||
if (
|
||||
data.get("start_date", None) is not None
|
||||
and data.get("end_date", None) is not None
|
||||
@@ -63,13 +75,6 @@ class CycleCreateSerializer(BaseSerializer):
|
||||
raise serializers.ValidationError("Start date cannot exceed end date")
|
||||
|
||||
if data.get("start_date", None) is not None and data.get("end_date", None) is not None:
|
||||
project_id = self.initial_data.get("project_id") or (
|
||||
self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None
|
||||
)
|
||||
|
||||
if not project_id:
|
||||
raise serializers.ValidationError("Project ID is required")
|
||||
|
||||
data["start_date"] = convert_to_utc(
|
||||
date=str(data.get("start_date").date()),
|
||||
project_id=project_id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .issue import IssueExpandSerializer
|
||||
from plane.db.models import IntakeIssue, Issue
|
||||
from plane.db.models import IntakeIssue, Issue, State, StateGroup
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
@@ -103,6 +103,52 @@ class IntakeIssueUpdateSerializer(BaseSerializer):
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""
|
||||
Validate that if status is being changed to accepted (1),
|
||||
the project has a default state to transition to.
|
||||
"""
|
||||
|
||||
# Check if status is being updated to accepted
|
||||
if attrs.get("status") == 1:
|
||||
intake_issue = self.instance
|
||||
issue = intake_issue.issue
|
||||
|
||||
# Check if issue is in TRIAGE state
|
||||
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
|
||||
# Verify default state exists before allowing the update
|
||||
default_state = State.objects.filter(
|
||||
workspace=intake_issue.workspace, project=intake_issue.project, default=True
|
||||
).first()
|
||||
|
||||
if not default_state:
|
||||
raise serializers.ValidationError(
|
||||
{"status": "Cannot accept intake issue: No default state found for the project"}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""
|
||||
Update intake issue and transition associated issue state if accepted.
|
||||
"""
|
||||
|
||||
# Update the intake issue with validated data
|
||||
instance = super().update(instance, validated_data)
|
||||
|
||||
# If status is accepted (1), update the associated issue state from TRIAGE to default
|
||||
if validated_data.get("status") == 1:
|
||||
issue = instance.issue
|
||||
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
|
||||
# Get the default project state
|
||||
default_state = State.objects.filter(
|
||||
workspace=instance.workspace, project=instance.project, default=True
|
||||
).first()
|
||||
if default_state:
|
||||
issue.state = default_state
|
||||
issue.save()
|
||||
return instance
|
||||
|
||||
|
||||
class IssueDataSerializer(serializers.Serializer):
|
||||
"""
|
||||
|
||||
@@ -10,6 +10,7 @@ from plane.db.models import (
|
||||
ModuleMember,
|
||||
ModuleIssue,
|
||||
ProjectMember,
|
||||
Project,
|
||||
)
|
||||
|
||||
|
||||
@@ -53,6 +54,14 @@ class ModuleCreateSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
project_id = self.context.get("project_id")
|
||||
if not project_id:
|
||||
raise serializers.ValidationError("Project ID is required")
|
||||
project = Project.objects.get(id=project_id)
|
||||
if not project:
|
||||
raise serializers.ValidationError("Project not found")
|
||||
if not project.module_view:
|
||||
raise serializers.ValidationError("Modules are not enabled for this project")
|
||||
if (
|
||||
data.get("start_date", None) is not None
|
||||
and data.get("target_date", None) is not None
|
||||
|
||||
@@ -171,7 +171,7 @@ class ProjectUpdateSerializer(ProjectCreateSerializer):
|
||||
|
||||
if (
|
||||
validated_data.get("estimate", None) is not None
|
||||
and not Estimate.objects.filter(project=instance, id=validated_data.get("estimate")).exists()
|
||||
and not Estimate.objects.filter(project=instance, id=validated_data.get("estimate").id).exists()
|
||||
):
|
||||
# Check if the estimate is a estimate in the project
|
||||
raise serializers.ValidationError("Estimate should be a estimate in the project")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import State
|
||||
from plane.db.models import State, StateGroup
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class StateSerializer(BaseSerializer):
|
||||
@@ -15,6 +16,9 @@ class StateSerializer(BaseSerializer):
|
||||
# If the default is being provided then make all other states default False
|
||||
if data.get("default", False):
|
||||
State.objects.filter(project_id=self.context.get("project_id")).update(default=False)
|
||||
|
||||
if data.get("group", None) == StateGroup.TRIAGE.value:
|
||||
raise serializers.ValidationError("Cannot create triage state")
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import Sticky
|
||||
from plane.utils.content_validator import validate_html_content, validate_binary_data
|
||||
|
||||
|
||||
class StickySerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Sticky
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "owner"]
|
||||
extra_kwargs = {"name": {"required": False}}
|
||||
|
||||
def validate(self, data):
|
||||
# Validate description content for security
|
||||
if "description_html" in data and data["description_html"]:
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"error": "html content is not valid"})
|
||||
# Update the data with sanitized HTML if available
|
||||
if sanitized_html is not None:
|
||||
data["description_html"] = sanitized_html
|
||||
|
||||
if "description_binary" in data and data["description_binary"]:
|
||||
is_valid, error_msg = validate_binary_data(data["description_binary"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
|
||||
|
||||
return data
|
||||
@@ -9,6 +9,7 @@ from .state import urlpatterns as state_patterns
|
||||
from .user import urlpatterns as user_patterns
|
||||
from .work_item import urlpatterns as work_item_patterns
|
||||
from .invite import urlpatterns as invite_patterns
|
||||
from .sticky import urlpatterns as sticky_patterns
|
||||
|
||||
urlpatterns = [
|
||||
*asset_patterns,
|
||||
@@ -22,4 +23,5 @@ urlpatterns = [
|
||||
*user_patterns,
|
||||
*work_item_patterns,
|
||||
*invite_patterns,
|
||||
*sticky_patterns,
|
||||
]
|
||||
|
||||
@@ -15,4 +15,4 @@ router.register(r"invitations", WorkspaceInvitationsViewset, basename="workspace
|
||||
# Wrap the router URLs with the workspace slug path
|
||||
urlpatterns = [
|
||||
path("workspaces/<str:slug>/", include(router.urls)),
|
||||
]
|
||||
]
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint
|
||||
from plane.api.views import (
|
||||
ProjectMemberListCreateAPIEndpoint,
|
||||
ProjectMemberDetailAPIEndpoint,
|
||||
WorkspaceMemberAPIEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
# Project members
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from plane.api.views import StickyViewSet
|
||||
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"stickies", StickyViewSet, basename="workspace-stickies")
|
||||
|
||||
urlpatterns = [
|
||||
path("workspaces/<str:slug>/", include(router.urls)),
|
||||
]
|
||||
@@ -54,4 +54,6 @@ from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpo
|
||||
|
||||
from .user import UserEndpoint
|
||||
|
||||
from .invite import WorkspaceInvitationsViewset
|
||||
from .invite import WorkspaceInvitationsViewset
|
||||
|
||||
from .sticky import StickyViewSet
|
||||
|
||||
@@ -13,8 +13,6 @@ from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework.generics import GenericAPIView
|
||||
|
||||
@@ -195,9 +195,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
|
||||
# Current Cycle
|
||||
if cycle_view == "current":
|
||||
queryset = queryset.filter(
|
||||
start_date__lte=timezone.now(), end_date__gte=timezone.now()
|
||||
)
|
||||
queryset = queryset.filter(start_date__lte=timezone.now(), end_date__gte=timezone.now())
|
||||
data = CycleSerializer(
|
||||
queryset,
|
||||
many=True,
|
||||
@@ -254,9 +252,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
|
||||
# Incomplete Cycles
|
||||
if cycle_view == "incomplete":
|
||||
queryset = queryset.filter(
|
||||
Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True)
|
||||
)
|
||||
queryset = queryset.filter(Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True))
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
@@ -302,17 +298,10 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
Create a new development cycle with specified name, description, and date range.
|
||||
Supports external ID tracking for integration purposes.
|
||||
"""
|
||||
if (
|
||||
request.data.get("start_date", None) is None
|
||||
and request.data.get("end_date", None) is None
|
||||
) or (
|
||||
request.data.get("start_date", None) is not None
|
||||
and request.data.get("end_date", None) is not None
|
||||
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
|
||||
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
|
||||
):
|
||||
|
||||
serializer = CycleCreateSerializer(
|
||||
data=request.data, context={"request": request}
|
||||
)
|
||||
serializer = CycleCreateSerializer(data=request.data, context={"request": request})
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
@@ -355,9 +344,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"error": "Both start date and end date are either required or are to be null"
|
||||
},
|
||||
{"error": "Both start date and end date are either required or are to be null"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -505,9 +492,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
|
||||
current_instance = json.dumps(
|
||||
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
|
||||
|
||||
if cycle.archived_at:
|
||||
return Response(
|
||||
@@ -520,20 +505,14 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||
if "sort_order" in request_data:
|
||||
# Can only change sort order
|
||||
request_data = {
|
||||
"sort_order": request_data.get("sort_order", cycle.sort_order)
|
||||
}
|
||||
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"error": "The Cycle has already been completed so it cannot be edited"
|
||||
},
|
||||
{"error": "The Cycle has already been completed so it cannot be edited"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = CycleUpdateSerializer(
|
||||
cycle, data=request.data, partial=True, context={"request": request}
|
||||
)
|
||||
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True, context={"request": request})
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
@@ -541,9 +520,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
and Cycle.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get(
|
||||
"external_source", cycle.external_source
|
||||
),
|
||||
external_source=request.data.get("external_source", cycle.external_source),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
@@ -600,11 +577,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
|
||||
"issue", flat=True
|
||||
)
|
||||
)
|
||||
cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
|
||||
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.deleted",
|
||||
@@ -624,9 +597,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
# Delete the cycle
|
||||
cycle.delete()
|
||||
# Delete the user favorite cycle
|
||||
UserFavorite.objects.filter(
|
||||
entity_type="cycle", entity_identifier=pk, project_id=project_id
|
||||
).delete()
|
||||
UserFavorite.objects.filter(entity_type="cycle", entity_identifier=pk, project_id=project_id).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -764,9 +735,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
on_results=lambda cycles: CycleSerializer(cycles, many=True, fields=self.fields, expand=self.expand).data,
|
||||
)
|
||||
|
||||
@cycle_docs(
|
||||
@@ -785,9 +754,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
Move a completed cycle to archived status for historical tracking.
|
||||
Only cycles that have ended can be archived.
|
||||
"""
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
|
||||
if cycle.end_date >= timezone.now():
|
||||
return Response(
|
||||
{"error": "Only completed cycles can be archived"},
|
||||
@@ -818,9 +785,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
Restore an archived cycle to active status, making it available for regular use.
|
||||
The cycle will reappear in active cycle lists.
|
||||
"""
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
|
||||
cycle.archived_at = None
|
||||
cycle.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -883,9 +848,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
# List
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
issues = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
|
||||
)
|
||||
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -922,9 +885,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(issues),
|
||||
on_results=lambda issues: IssueSerializer(
|
||||
issues, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data,
|
||||
)
|
||||
|
||||
@cycle_docs(
|
||||
@@ -958,9 +919,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
)
|
||||
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=cycle_id)
|
||||
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||
return Response(
|
||||
@@ -972,13 +931,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
# Get all CycleWorkItems already created
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues)
|
||||
)
|
||||
cycle_issues = list(CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues))
|
||||
existing_issues = [
|
||||
str(cycle_issue.issue_id)
|
||||
for cycle_issue in cycle_issues
|
||||
if str(cycle_issue.issue_id) in issues
|
||||
str(cycle_issue.issue_id) for cycle_issue in cycle_issues if str(cycle_issue.issue_id) in issues
|
||||
]
|
||||
new_issues = list(set(issues) - set(existing_issues))
|
||||
|
||||
@@ -1029,9 +984,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": serializers.serialize(
|
||||
"json", created_records
|
||||
),
|
||||
"created_cycle_issues": serializers.serialize("json", created_records),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
@@ -1107,9 +1060,7 @@ class CycleIssueDetailAPIEndpoint(BaseAPIView):
|
||||
cycle_id=cycle_id,
|
||||
issue_id=issue_id,
|
||||
)
|
||||
serializer = CycleIssueSerializer(
|
||||
cycle_issue, fields=self.fields, expand=self.expand
|
||||
)
|
||||
serializer = CycleIssueSerializer(cycle_issue, fields=self.fields, expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@cycle_docs(
|
||||
@@ -1214,7 +1165,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
{"error": "New Cycle Id is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
old_cycle = Cycle.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
|
||||
@@ -23,7 +23,7 @@ from plane.api.serializers import (
|
||||
)
|
||||
from plane.app.permissions import ProjectLitePermission
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State
|
||||
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State, StateGroup
|
||||
from plane.utils.host import base_host
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models.intake import SourceType
|
||||
@@ -165,6 +165,20 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
]:
|
||||
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# get the triage state
|
||||
triage_state = State.triage_objects.filter(project_id=project_id, workspace__slug=slug).first()
|
||||
|
||||
if not triage_state:
|
||||
triage_state = State.objects.create(
|
||||
name="Triage",
|
||||
group=StateGroup.TRIAGE.value,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
color="#4E5355",
|
||||
sequence=65000,
|
||||
default=False,
|
||||
)
|
||||
|
||||
# create an issue
|
||||
issue = Issue.objects.create(
|
||||
name=request.data.get("issue", {}).get("name"),
|
||||
@@ -172,6 +186,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
|
||||
priority=request.data.get("issue", {}).get("priority", "none"),
|
||||
project_id=project_id,
|
||||
state_id=triage_state.id,
|
||||
)
|
||||
|
||||
# create an intake issue
|
||||
@@ -320,7 +335,10 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||
|
||||
# Get issue data
|
||||
issue_data = request.data.pop("issue", False)
|
||||
issue_serializer = None
|
||||
intake_serializer = None
|
||||
|
||||
# Validate issue data if provided
|
||||
if bool(issue_data):
|
||||
issue = Issue.objects.annotate(
|
||||
label_ids=Coalesce(
|
||||
@@ -344,6 +362,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
).get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
||||
|
||||
# Only allow guests to edit name and description
|
||||
if project_member.role <= 5:
|
||||
issue_data = {
|
||||
@@ -354,71 +373,55 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||
|
||||
issue_serializer = IssueSerializer(issue, data=issue_data, partial=True)
|
||||
|
||||
if issue_serializer.is_valid():
|
||||
current_instance = issue
|
||||
# Log all the updates
|
||||
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
|
||||
if issue is not None:
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
IssueSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
intake=(intake_issue.id),
|
||||
)
|
||||
issue_serializer.save()
|
||||
else:
|
||||
if not issue_serializer.is_valid():
|
||||
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Only project admins and members can edit intake issue attributes
|
||||
if project_member.role > 15:
|
||||
serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
|
||||
intake_serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
|
||||
|
||||
if not intake_serializer.is_valid():
|
||||
return Response(intake_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Both serializers are valid, now save them
|
||||
if issue_serializer:
|
||||
current_instance = issue
|
||||
# Log all the updates
|
||||
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
IssueSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
intake=str(intake_issue.id),
|
||||
)
|
||||
issue_serializer.save()
|
||||
|
||||
# Save intake issue (state transition happens in serializer's update method)
|
||||
if intake_serializer:
|
||||
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
|
||||
intake_serializer.save()
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# Update the issue state if the issue is rejected or marked as duplicate
|
||||
if serializer.data["status"] in [-1, 2]:
|
||||
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
||||
state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first()
|
||||
if state is not None:
|
||||
issue.state = state
|
||||
issue.save()
|
||||
|
||||
# Update the issue state if it is accepted
|
||||
if serializer.data["status"] in [1]:
|
||||
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
||||
|
||||
# Update the issue state only if it is in triage state
|
||||
if issue.state.is_triage:
|
||||
# Move to default state
|
||||
state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first()
|
||||
if state is not None:
|
||||
issue.state = state
|
||||
issue.save()
|
||||
|
||||
# create a activity for status change
|
||||
issue_activity.delay(
|
||||
type="intake.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=False,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
intake=str(intake_issue.id),
|
||||
)
|
||||
serializer = IntakeIssueSerializer(intake_issue)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
# create a activity for status change
|
||||
issue_activity.delay(
|
||||
type="intake.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=False,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
intake=str(intake_issue.id),
|
||||
)
|
||||
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@@ -154,7 +154,6 @@ class ProjectMemberListCreateAPIEndpoint(BaseAPIView):
|
||||
|
||||
# API endpoint to get and update a project member
|
||||
class ProjectMemberDetailAPIEndpoint(ProjectMemberListCreateAPIEndpoint):
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_project_member",
|
||||
summary="Get project member",
|
||||
|
||||
@@ -65,9 +65,7 @@ from plane.utils.openapi import (
|
||||
ADMIN_ONLY_RESPONSE,
|
||||
REQUIRED_FIELDS_RESPONSE,
|
||||
MODULE_ISSUE_NOT_FOUND_RESPONSE,
|
||||
ARCHIVED_RESPONSE,
|
||||
CANNOT_ARCHIVE_RESPONSE,
|
||||
UNARCHIVED_RESPONSE,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from plane.db.models import (
|
||||
DeployBoard,
|
||||
ProjectMember,
|
||||
State,
|
||||
DEFAULT_STATES,
|
||||
Workspace,
|
||||
UserFavorite,
|
||||
)
|
||||
@@ -232,41 +233,6 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||
user_id=serializer.instance.project_lead,
|
||||
)
|
||||
|
||||
# Default states
|
||||
states = [
|
||||
{
|
||||
"name": "Backlog",
|
||||
"color": "#60646C",
|
||||
"sequence": 15000,
|
||||
"group": "backlog",
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"name": "Todo",
|
||||
"color": "#60646C",
|
||||
"sequence": 25000,
|
||||
"group": "unstarted",
|
||||
},
|
||||
{
|
||||
"name": "In Progress",
|
||||
"color": "#F59E0B",
|
||||
"sequence": 35000,
|
||||
"group": "started",
|
||||
},
|
||||
{
|
||||
"name": "Done",
|
||||
"color": "#46A758",
|
||||
"sequence": 45000,
|
||||
"group": "completed",
|
||||
},
|
||||
{
|
||||
"name": "Cancelled",
|
||||
"color": "#9AA4BC",
|
||||
"sequence": 55000,
|
||||
"group": "cancelled",
|
||||
},
|
||||
]
|
||||
|
||||
State.objects.bulk_create(
|
||||
[
|
||||
State(
|
||||
@@ -279,7 +245,7 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||
default=state.get("default", False),
|
||||
created_by=request.user,
|
||||
)
|
||||
for state in states
|
||||
for state in DEFAULT_STATES
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
from plane.api.views.base import BaseViewSet
|
||||
from plane.app.permissions import WorkspaceUserPermission
|
||||
from plane.db.models import Sticky, Workspace
|
||||
from plane.api.serializers import StickySerializer
|
||||
|
||||
# OpenAPI imports
|
||||
from plane.utils.openapi.decorators import sticky_docs
|
||||
|
||||
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
|
||||
from plane.utils.openapi import (
|
||||
STICKY_EXAMPLE,
|
||||
create_paginated_response,
|
||||
DELETED_RESPONSE,
|
||||
)
|
||||
|
||||
|
||||
class StickyViewSet(BaseViewSet):
|
||||
serializer_class = StickySerializer
|
||||
model = Sticky
|
||||
use_read_replica = True
|
||||
permission_classes = [WorkspaceUserPermission]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(owner_id=self.request.user.id)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@sticky_docs(
|
||||
operation_id="create_sticky",
|
||||
summary="Create a new sticky",
|
||||
description="Create a new sticky in the workspace",
|
||||
request=OpenApiRequest(request=StickySerializer),
|
||||
responses={
|
||||
201: OpenApiResponse(description="Sticky created", response=StickySerializer, examples=[STICKY_EXAMPLE])
|
||||
},
|
||||
)
|
||||
def create(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = StickySerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(workspace_id=workspace.id, owner_id=request.user.id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@sticky_docs(
|
||||
operation_id="list_stickies",
|
||||
summary="List stickies",
|
||||
description="List all stickies in the workspace",
|
||||
responses={
|
||||
200: create_paginated_response(
|
||||
StickySerializer, "Sticky", "List of stickies", example_name="List of stickies"
|
||||
)
|
||||
},
|
||||
)
|
||||
def list(self, request, slug):
|
||||
query = request.query_params.get("query", False)
|
||||
stickies = self.get_queryset().order_by("-created_at")
|
||||
if query:
|
||||
stickies = stickies.filter(description_stripped__icontains=query)
|
||||
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(stickies),
|
||||
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
|
||||
default_per_page=20,
|
||||
)
|
||||
|
||||
@sticky_docs(
|
||||
operation_id="retrieve_sticky",
|
||||
summary="Retrieve a sticky",
|
||||
description="Retrieve a sticky by its ID",
|
||||
responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])},
|
||||
)
|
||||
def retrieve(self, request, slug, pk):
|
||||
sticky = self.get_object()
|
||||
return Response(StickySerializer(sticky).data)
|
||||
|
||||
@sticky_docs(
|
||||
operation_id="update_sticky",
|
||||
summary="Update a sticky",
|
||||
description="Update a sticky by its ID",
|
||||
request=OpenApiRequest(request=StickySerializer),
|
||||
responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])},
|
||||
)
|
||||
def partial_update(self, request, slug, pk):
|
||||
sticky = self.get_object()
|
||||
serializer = StickySerializer(sticky, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@sticky_docs(
|
||||
operation_id="delete_sticky",
|
||||
summary="Delete a sticky",
|
||||
description="Delete a sticky by its ID",
|
||||
responses={204: DELETED_RESPONSE},
|
||||
)
|
||||
def destroy(self, request, slug, pk):
|
||||
sticky = self.get_object()
|
||||
sticky.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -37,6 +37,7 @@ from .project import (
|
||||
ProjectMemberAdminSerializer,
|
||||
ProjectPublicMemberSerializer,
|
||||
ProjectMemberRoleSerializer,
|
||||
ProjectMemberPreferenceSerializer,
|
||||
)
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .view import IssueViewSerializer, ViewIssueListSerializer
|
||||
|
||||
@@ -7,7 +7,7 @@ from .issue import IssueIntakeSerializer, LabelLiteSerializer, IssueDetailSerial
|
||||
from .project import ProjectLiteSerializer
|
||||
from .state import StateLiteSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from plane.db.models import Intake, IntakeIssue, Issue
|
||||
from plane.db.models import Intake, IntakeIssue, Issue, StateGroup, State
|
||||
|
||||
|
||||
class IntakeSerializer(BaseSerializer):
|
||||
@@ -36,6 +36,49 @@ class IntakeIssueSerializer(BaseSerializer):
|
||||
]
|
||||
read_only_fields = ["project", "workspace"]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""
|
||||
Validate that if status is being changed to accepted (1),
|
||||
the project has a default state to transition to.
|
||||
"""
|
||||
|
||||
# Check if status is being updated to accepted
|
||||
if attrs.get("status") == 1:
|
||||
intake_issue = self.instance
|
||||
issue = intake_issue.issue
|
||||
|
||||
# Check if issue is in TRIAGE state
|
||||
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
|
||||
# Verify default state exists before allowing the update
|
||||
default_state = State.objects.filter(
|
||||
workspace=intake_issue.workspace, project=intake_issue.project, default=True
|
||||
).first()
|
||||
|
||||
if not default_state:
|
||||
raise serializers.ValidationError(
|
||||
{"status": "Cannot accept intake issue: No default state found for the project"}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Update the intake issue
|
||||
instance = super().update(instance, validated_data)
|
||||
|
||||
# If status is accepted (1), transition the issue state from TRIAGE to default
|
||||
if validated_data.get("status") == 1:
|
||||
issue = instance.issue
|
||||
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
|
||||
# Get the default project state
|
||||
default_state = State.objects.filter(
|
||||
workspace=instance.workspace, project=instance.project, default=True
|
||||
).first()
|
||||
if default_state:
|
||||
issue.state = default_state
|
||||
issue.save()
|
||||
|
||||
return instance
|
||||
|
||||
def to_representation(self, instance):
|
||||
# Pass the annotated fields to the Issue instance if they exist
|
||||
if hasattr(instance, "label_ids"):
|
||||
|
||||
@@ -78,7 +78,7 @@ class IssueProjectLiteSerializer(BaseSerializer):
|
||||
class IssueCreateSerializer(BaseSerializer):
|
||||
# ids
|
||||
state_id = serializers.PrimaryKeyRelatedField(
|
||||
source="state", queryset=State.objects.all(), required=False, allow_null=True
|
||||
source="state", queryset=State.all_state_objects.all(), required=False, allow_null=True
|
||||
)
|
||||
parent_id = serializers.PrimaryKeyRelatedField(
|
||||
source="parent", queryset=Issue.objects.all(), required=False, allow_null=True
|
||||
@@ -117,6 +117,9 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
return data
|
||||
|
||||
def validate(self, attrs):
|
||||
allow_triage = self.context.get("allow_triage_state", False)
|
||||
state_manager = State.triage_objects if allow_triage else State.objects
|
||||
|
||||
if (
|
||||
attrs.get("start_date", None) is not None
|
||||
and attrs.get("target_date", None) is not None
|
||||
@@ -160,7 +163,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
# Check state is from the project only else raise validation error
|
||||
if (
|
||||
attrs.get("state")
|
||||
and not State.objects.filter(
|
||||
and not state_manager.filter(
|
||||
project_id=self.context.get("project_id"),
|
||||
pk=attrs.get("state").id,
|
||||
).exists()
|
||||
@@ -795,6 +798,14 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def validate(self, data):
|
||||
if (
|
||||
data.get("state_id")
|
||||
and not State.objects.filter(project_id=self.context.get("project_id"), pk=data.get("state_id")).exists()
|
||||
):
|
||||
raise serializers.ValidationError("State is not valid please pass a valid state_id")
|
||||
return data
|
||||
|
||||
|
||||
class IssueListDetailSerializer(serializers.Serializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -142,6 +142,18 @@ class ProjectMemberSerializer(BaseSerializer):
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class ProjectMemberPreferenceSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = ProjectMember
|
||||
fields = ["preferences", "project_id", "member_id", "workspace_id"]
|
||||
|
||||
def validate_preferences(self, value):
|
||||
preferences = self.instance.preferences
|
||||
|
||||
preferences.update(value)
|
||||
return preferences
|
||||
|
||||
|
||||
class ProjectMemberAdminSerializer(BaseSerializer):
|
||||
workspace = WorkspaceLiteSerializer(read_only=True)
|
||||
project = ProjectLiteSerializer(read_only=True)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from .base import BaseSerializer
|
||||
from rest_framework import serializers
|
||||
|
||||
from plane.db.models import State
|
||||
from plane.db.models import State, StateGroup
|
||||
|
||||
|
||||
class StateSerializer(BaseSerializer):
|
||||
@@ -24,6 +24,11 @@ class StateSerializer(BaseSerializer):
|
||||
]
|
||||
read_only_fields = ["workspace", "project"]
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs.get("group") == StateGroup.TRIAGE.value:
|
||||
raise serializers.ValidationError("Cannot create triage state")
|
||||
return attrs
|
||||
|
||||
|
||||
class StateLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
|
||||
@@ -9,4 +9,4 @@ urlpatterns = [
|
||||
ExportIssuesEndpoint.as_view(),
|
||||
name="export-issues",
|
||||
),
|
||||
]
|
||||
]
|
||||
|
||||
@@ -11,9 +11,9 @@ from plane.app.views import (
|
||||
ProjectIdentifierEndpoint,
|
||||
ProjectFavoritesViewSet,
|
||||
UserProjectInvitationsViewset,
|
||||
ProjectPublicCoverImagesEndpoint,
|
||||
UserProjectRolesEndpoint,
|
||||
ProjectArchiveUnarchiveEndpoint,
|
||||
ProjectMemberPreferenceEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -105,11 +105,6 @@ urlpatterns = [
|
||||
ProjectFavoritesViewSet.as_view({"delete": "destroy"}),
|
||||
name="project-favorite",
|
||||
),
|
||||
path(
|
||||
"project-covers/",
|
||||
ProjectPublicCoverImagesEndpoint.as_view(),
|
||||
name="project-covers",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/",
|
||||
DeployBoardViewSet.as_view({"get": "list", "post": "create"}),
|
||||
@@ -125,4 +120,9 @@ urlpatterns = [
|
||||
ProjectArchiveUnarchiveEndpoint.as_view(),
|
||||
name="project-archive-unarchive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/preferences/member/<uuid:member_id>/",
|
||||
ProjectMemberPreferenceEndpoint.as_view(),
|
||||
name="project-member-preference",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import StateViewSet
|
||||
from plane.app.views import StateViewSet, IntakeStateEndpoint
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
@@ -15,6 +15,11 @@ urlpatterns = [
|
||||
StateViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
|
||||
name="project-state",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-state/",
|
||||
IntakeStateEndpoint.as_view(),
|
||||
name="intake-state",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:pk>/mark-default/",
|
||||
StateViewSet.as_view({"post": "mark_as_default"}),
|
||||
|
||||
@@ -253,9 +253,4 @@ urlpatterns = [
|
||||
WorkspaceUserPreferenceViewSet.as_view(),
|
||||
name="workspace-user-preference",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/sidebar-preferences/<str:key>/",
|
||||
WorkspaceUserPreferenceViewSet.as_view(),
|
||||
name="workspace-user-preference",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -3,7 +3,6 @@ from .project.base import (
|
||||
ProjectIdentifierEndpoint,
|
||||
ProjectUserViewsEndpoint,
|
||||
ProjectFavoritesViewSet,
|
||||
ProjectPublicCoverImagesEndpoint,
|
||||
DeployBoardViewSet,
|
||||
ProjectArchiveUnarchiveEndpoint,
|
||||
)
|
||||
@@ -18,6 +17,7 @@ from .project.member import (
|
||||
ProjectMemberViewSet,
|
||||
ProjectMemberUserEndpoint,
|
||||
UserProjectRolesEndpoint,
|
||||
ProjectMemberPreferenceEndpoint,
|
||||
)
|
||||
|
||||
from .user.base import (
|
||||
@@ -79,7 +79,7 @@ from .workspace.cycle import WorkspaceCyclesEndpoint
|
||||
from .workspace.quick_link import QuickLinkViewSet
|
||||
from .workspace.sticky import WorkspaceStickyViewSet
|
||||
|
||||
from .state.base import StateViewSet
|
||||
from .state.base import StateViewSet, IntakeStateEndpoint
|
||||
from .view.base import (
|
||||
WorkspaceViewViewSet,
|
||||
WorkspaceViewIssuesViewSet,
|
||||
|
||||
@@ -45,9 +45,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
# Save the new avatar
|
||||
user.avatar_asset_id = asset_id
|
||||
user.save()
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/", url_params=False, user=True, request=request
|
||||
)
|
||||
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/settings/",
|
||||
url_params=False,
|
||||
@@ -65,9 +63,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
# Save the new cover image
|
||||
user.cover_image_asset_id = asset_id
|
||||
user.save()
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/", url_params=False, user=True, request=request
|
||||
)
|
||||
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/settings/",
|
||||
url_params=False,
|
||||
@@ -83,9 +79,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
user = User.objects.get(id=asset.user_id)
|
||||
user.avatar_asset_id = None
|
||||
user.save()
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/", url_params=False, user=True, request=request
|
||||
)
|
||||
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/settings/",
|
||||
url_params=False,
|
||||
@@ -98,9 +92,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
user = User.objects.get(id=asset.user_id)
|
||||
user.cover_image_asset_id = None
|
||||
user.save()
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/", url_params=False, user=True, request=request
|
||||
)
|
||||
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/settings/",
|
||||
url_params=False,
|
||||
@@ -160,9 +152,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key, file_type=type, file_size=size_limit
|
||||
)
|
||||
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
@@ -199,9 +189,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_delete(
|
||||
entity_type=asset.entity_type, asset=asset, request=request
|
||||
)
|
||||
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -265,18 +253,14 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
workspace.logo = ""
|
||||
workspace.logo_asset_id = asset_id
|
||||
workspace.save()
|
||||
invalidate_cache_directly(
|
||||
path="/api/workspaces/", url_params=False, user=False, request=request
|
||||
)
|
||||
invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request)
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/workspaces/",
|
||||
url_params=False,
|
||||
user=True,
|
||||
request=request,
|
||||
)
|
||||
invalidate_cache_directly(
|
||||
path="/api/instances/", url_params=False, user=False, request=request
|
||||
)
|
||||
invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request)
|
||||
return
|
||||
|
||||
# Project Cover
|
||||
@@ -303,18 +287,14 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
return
|
||||
workspace.logo_asset_id = None
|
||||
workspace.save()
|
||||
invalidate_cache_directly(
|
||||
path="/api/workspaces/", url_params=False, user=False, request=request
|
||||
)
|
||||
invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request)
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/workspaces/",
|
||||
url_params=False,
|
||||
user=True,
|
||||
request=request,
|
||||
)
|
||||
invalidate_cache_directly(
|
||||
path="/api/instances/", url_params=False, user=False, request=request
|
||||
)
|
||||
invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request)
|
||||
return
|
||||
# Project Cover
|
||||
elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
|
||||
@@ -375,17 +355,13 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
entity_type=entity_type,
|
||||
**self.get_entity_id_field(
|
||||
entity_type=entity_type, entity_id=entity_identifier
|
||||
),
|
||||
**self.get_entity_id_field(entity_type=entity_type, entity_id=entity_identifier),
|
||||
)
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key, file_type=type, file_size=size_limit
|
||||
)
|
||||
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
@@ -422,9 +398,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_delete(
|
||||
entity_type=asset.entity_type, asset=asset, request=request
|
||||
)
|
||||
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -587,9 +561,7 @@ class ProjectAssetEndpoint(BaseAPIView):
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key, file_type=type, file_size=size_limit
|
||||
)
|
||||
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
@@ -619,9 +591,7 @@ class ProjectAssetEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def delete(self, request, slug, project_id, pk):
|
||||
# Get the asset
|
||||
asset = FileAsset.objects.get(
|
||||
id=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
|
||||
# Check deleted assets
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
@@ -632,9 +602,7 @@ class ProjectAssetEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, pk):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
asset = FileAsset.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
|
||||
# Check if the asset is uploaded
|
||||
if not asset.is_uploaded:
|
||||
@@ -667,9 +635,7 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
|
||||
|
||||
# Check if the asset ids are provided
|
||||
if not asset_ids:
|
||||
return Response(
|
||||
{"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response({"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# get the asset id
|
||||
assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug)
|
||||
@@ -723,14 +689,11 @@ class AssetCheckEndpoint(BaseAPIView):
|
||||
|
||||
@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()
|
||||
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 DuplicateAssetEndpoint(BaseAPIView):
|
||||
|
||||
throttle_classes = [AssetRateThrottle]
|
||||
|
||||
def get_entity_id_field(self, entity_type, entity_id):
|
||||
@@ -766,17 +729,13 @@ class DuplicateAssetEndpoint(BaseAPIView):
|
||||
|
||||
return {}
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def post(self, request, slug, asset_id):
|
||||
project_id = request.data.get("project_id", None)
|
||||
entity_id = request.data.get("entity_id", None)
|
||||
entity_type = request.data.get("entity_type", None)
|
||||
|
||||
|
||||
if (
|
||||
not entity_type
|
||||
or entity_type not in FileAsset.EntityTypeContext.values
|
||||
):
|
||||
if not entity_type or entity_type not in FileAsset.EntityTypeContext.values:
|
||||
return Response(
|
||||
{"error": "Invalid entity type or entity id"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -786,23 +745,15 @@ class DuplicateAssetEndpoint(BaseAPIView):
|
||||
if project_id:
|
||||
# check if project exists in the workspace
|
||||
if not Project.objects.filter(id=project_id, workspace=workspace).exists():
|
||||
return Response(
|
||||
{"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
storage = S3Storage(request=request)
|
||||
original_asset = FileAsset.objects.filter(
|
||||
workspace=workspace, id=asset_id, is_uploaded=True
|
||||
).first()
|
||||
original_asset = FileAsset.objects.filter(id=asset_id, is_uploaded=True).first()
|
||||
|
||||
if not original_asset:
|
||||
return Response(
|
||||
{"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
destination_key = (
|
||||
f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
|
||||
)
|
||||
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
|
||||
duplicated_asset = FileAsset.objects.create(
|
||||
attributes={
|
||||
"name": original_asset.attributes.get("name"),
|
||||
@@ -822,9 +773,7 @@ class DuplicateAssetEndpoint(BaseAPIView):
|
||||
# Update the is_uploaded field for all newly created assets
|
||||
FileAsset.objects.filter(id=duplicated_asset.id).update(is_uploaded=True)
|
||||
|
||||
return Response(
|
||||
{"asset_id": str(duplicated_asset.id)}, status=status.HTTP_200_OK
|
||||
)
|
||||
return Response({"asset_id": str(duplicated_asset.id)}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class WorkspaceAssetDownloadEndpoint(BaseAPIView):
|
||||
|
||||
@@ -97,9 +97,7 @@ class CycleViewSet(BaseViewSet):
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only(
|
||||
"avatar_asset", "first_name", "id"
|
||||
).distinct(),
|
||||
queryset=User.objects.only("avatar_asset", "first_name", "id").distinct(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
@@ -150,8 +148,7 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
Q(start_date__lte=current_time_in_utc)
|
||||
& Q(end_date__gte=current_time_in_utc),
|
||||
Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")),
|
||||
@@ -170,11 +167,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"issue_cycle__issue__assignees__id",
|
||||
distinct=True,
|
||||
filter=~Q(issue_cycle__issue__assignees__id__isnull=True)
|
||||
& (
|
||||
Q(
|
||||
issue_cycle__issue__issue_assignee__deleted_at__isnull=True
|
||||
)
|
||||
),
|
||||
& (Q(issue_cycle__issue__issue_assignee__deleted_at__isnull=True)),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
)
|
||||
@@ -205,9 +198,7 @@ class CycleViewSet(BaseViewSet):
|
||||
|
||||
# Current Cycle
|
||||
if cycle_view == "current":
|
||||
queryset = queryset.filter(
|
||||
start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc
|
||||
)
|
||||
queryset = queryset.filter(start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc)
|
||||
|
||||
data = queryset.values(
|
||||
# necessary fields
|
||||
@@ -274,16 +265,10 @@ class CycleViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def create(self, request, slug, project_id):
|
||||
if (
|
||||
request.data.get("start_date", None) is None
|
||||
and request.data.get("end_date", None) is None
|
||||
) or (
|
||||
request.data.get("start_date", None) is not None
|
||||
and request.data.get("end_date", None) is not None
|
||||
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
|
||||
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
|
||||
):
|
||||
serializer = CycleWriteSerializer(
|
||||
data=request.data, context={"project_id": project_id}
|
||||
)
|
||||
serializer = CycleWriteSerializer(data=request.data, context={"project_id": project_id})
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id, owned_by=request.user)
|
||||
cycle = (
|
||||
@@ -323,9 +308,7 @@ 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(
|
||||
@@ -341,17 +324,13 @@ class CycleViewSet(BaseViewSet):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"error": "Both start date and end date are either required or are to be null"
|
||||
},
|
||||
{"error": "Both start date and end date are either required or are to be null"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
queryset = self.get_queryset().filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
queryset = self.get_queryset().filter(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
cycle = queryset.first()
|
||||
if cycle.archived_at:
|
||||
return Response(
|
||||
@@ -359,29 +338,21 @@ class CycleViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
current_instance = json.dumps(
|
||||
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
|
||||
|
||||
request_data = request.data
|
||||
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||
if "sort_order" in request_data:
|
||||
# Can only change sort order for a completed cycle``
|
||||
request_data = {
|
||||
"sort_order": request_data.get("sort_order", cycle.sort_order)
|
||||
}
|
||||
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"error": "The Cycle has already been completed so it cannot be edited"
|
||||
},
|
||||
{"error": "The Cycle has already been completed so it cannot be edited"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = CycleWriteSerializer(
|
||||
cycle, data=request.data, partial=True, context={"project_id": project_id}
|
||||
)
|
||||
serializer = CycleWriteSerializer(cycle, data=request.data, partial=True, context={"project_id": project_id})
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
cycle = queryset.values(
|
||||
@@ -481,9 +452,7 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if data is None:
|
||||
return Response(
|
||||
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
queryset = queryset.first()
|
||||
# Fetch the project timezone
|
||||
@@ -505,11 +474,7 @@ class CycleViewSet(BaseViewSet):
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
|
||||
"issue", flat=True
|
||||
)
|
||||
)
|
||||
cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
|
||||
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.deleted",
|
||||
@@ -560,9 +525,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
start_date = convert_to_utc(
|
||||
date=str(start_date), project_id=project_id, is_start_date=True
|
||||
)
|
||||
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,
|
||||
@@ -666,12 +629,8 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
cycle_properties.filters = request.data.get("filters", cycle_properties.filters)
|
||||
cycle_properties.rich_filters = request.data.get(
|
||||
"rich_filters", cycle_properties.rich_filters
|
||||
)
|
||||
cycle_properties.display_filters = request.data.get(
|
||||
"display_filters", cycle_properties.display_filters
|
||||
)
|
||||
cycle_properties.rich_filters = request.data.get("rich_filters", cycle_properties.rich_filters)
|
||||
cycle_properties.display_filters = request.data.get("display_filters", cycle_properties.display_filters)
|
||||
cycle_properties.display_properties = request.data.get(
|
||||
"display_properties", cycle_properties.display_properties
|
||||
)
|
||||
@@ -695,13 +654,9 @@ 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()
|
||||
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
|
||||
)
|
||||
return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
aggregate_estimates = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
@@ -747,9 +702,7 @@ class CycleProgressEndpoint(BaseAPIView):
|
||||
output_field=FloatField(),
|
||||
)
|
||||
),
|
||||
total_estimate_points=Sum(
|
||||
"value_as_float", default=Value(0), output_field=FloatField()
|
||||
),
|
||||
total_estimate_points=Sum("value_as_float", default=Value(0), output_field=FloatField()),
|
||||
)
|
||||
)
|
||||
if cycle.progress_snapshot:
|
||||
@@ -809,22 +762,11 @@ class CycleProgressEndpoint(BaseAPIView):
|
||||
|
||||
return Response(
|
||||
{
|
||||
"backlog_estimate_points": aggregate_estimates["backlog_estimate_point"]
|
||||
or 0,
|
||||
"unstarted_estimate_points": aggregate_estimates[
|
||||
"unstarted_estimate_point"
|
||||
]
|
||||
or 0,
|
||||
"started_estimate_points": aggregate_estimates["started_estimate_point"]
|
||||
or 0,
|
||||
"cancelled_estimate_points": aggregate_estimates[
|
||||
"cancelled_estimate_point"
|
||||
]
|
||||
or 0,
|
||||
"completed_estimate_points": aggregate_estimates[
|
||||
"completed_estimate_points"
|
||||
]
|
||||
or 0,
|
||||
"backlog_estimate_points": aggregate_estimates["backlog_estimate_point"] or 0,
|
||||
"unstarted_estimate_points": aggregate_estimates["unstarted_estimate_point"] or 0,
|
||||
"started_estimate_points": aggregate_estimates["started_estimate_point"] or 0,
|
||||
"cancelled_estimate_points": aggregate_estimates["cancelled_estimate_point"] or 0,
|
||||
"completed_estimate_points": aggregate_estimates["completed_estimate_points"] or 0,
|
||||
"total_estimate_points": aggregate_estimates["total_estimate_points"],
|
||||
"backlog_issues": backlog_issues,
|
||||
"total_issues": total_issues,
|
||||
@@ -842,9 +784,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id, cycle_id):
|
||||
analytic_type = request.GET.get("type", "issues")
|
||||
cycle = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, id=cycle_id
|
||||
)
|
||||
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
@@ -927,9 +867,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(
|
||||
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
|
||||
)
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
@@ -964,9 +902,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
|
||||
)
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
@@ -1068,11 +1004,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"label_id", filter=Q(archived_at__isnull=True, is_draft=False)
|
||||
)
|
||||
)
|
||||
.annotate(total_issues=Count("label_id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"label_id",
|
||||
|
||||
@@ -22,6 +22,7 @@ from plane.db.models import (
|
||||
IntakeIssue,
|
||||
Issue,
|
||||
State,
|
||||
StateGroup,
|
||||
IssueLink,
|
||||
FileAsset,
|
||||
Project,
|
||||
@@ -228,14 +229,30 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
]:
|
||||
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# create an issue
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
# get the triage state
|
||||
triage_state = State.triage_objects.filter(project_id=project_id, workspace__slug=slug).first()
|
||||
if not triage_state:
|
||||
triage_state = State.objects.create(
|
||||
name="Triage",
|
||||
group=StateGroup.TRIAGE.value,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
color="#4E5355",
|
||||
sequence=65000,
|
||||
default=False,
|
||||
)
|
||||
request.data["issue"]["state_id"] = triage_state.id
|
||||
|
||||
# create an issue
|
||||
serializer = IssueCreateSerializer(
|
||||
data=request.data.get("issue"),
|
||||
context={
|
||||
"project_id": project_id,
|
||||
"workspace_id": project.workspace_id,
|
||||
"default_assignee_id": project.default_assignee_id,
|
||||
"allow_triage_state": True,
|
||||
},
|
||||
)
|
||||
if serializer.is_valid():
|
||||
@@ -305,6 +322,9 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue)
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
skip_activity = request.data.pop("skip_activity", False)
|
||||
is_description_update = request.data.get("description_html") is not None
|
||||
|
||||
intake_id = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first()
|
||||
intake_issue = IntakeIssue.objects.get(
|
||||
issue_id=pk,
|
||||
@@ -344,6 +364,12 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
|
||||
# Get issue data
|
||||
issue_data = request.data.pop("issue", False)
|
||||
issue_serializer = None
|
||||
issue = None
|
||||
issue_current_instance = None
|
||||
issue_requested_data = None
|
||||
|
||||
# Validate issue data if provided
|
||||
if bool(issue_data):
|
||||
issue = Issue.objects.annotate(
|
||||
label_ids=Coalesce(
|
||||
@@ -371,23 +397,43 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
"description": issue_data.get("description", issue.description),
|
||||
}
|
||||
|
||||
current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
|
||||
issue_current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
|
||||
issue_requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
|
||||
|
||||
issue_serializer = IssueCreateSerializer(
|
||||
issue, data=issue_data, partial=True, context={"project_id": project_id}
|
||||
issue, data=issue_data, partial=True, context={"project_id": project_id, "allow_triage_state": True}
|
||||
)
|
||||
|
||||
if issue_serializer.is_valid():
|
||||
# Log all the updates
|
||||
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
|
||||
if not issue_serializer.is_valid():
|
||||
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Validate intake issue data if user has permission
|
||||
intake_serializer = None
|
||||
intake_current_instance = None
|
||||
|
||||
if (project_member and project_member.role > ROLE.MEMBER.value) or is_workspace_admin:
|
||||
intake_current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
|
||||
intake_serializer = IntakeIssueSerializer(intake_issue, data=request.data, partial=True)
|
||||
|
||||
if not intake_serializer.is_valid():
|
||||
return Response(intake_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Both serializers are valid, now save them
|
||||
if issue_serializer:
|
||||
issue_serializer.save()
|
||||
|
||||
# Check if the update is a migration description update
|
||||
is_migration_description_update = skip_activity and is_description_update
|
||||
# Log all the updates
|
||||
if not is_migration_description_update:
|
||||
if issue is not None:
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=requested_data,
|
||||
requested_data=issue_requested_data,
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
current_instance=issue_current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
@@ -395,95 +441,55 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
)
|
||||
# updated issue description version
|
||||
issue_description_version_task.delay(
|
||||
updated_issue=current_instance,
|
||||
updated_issue=issue_current_instance,
|
||||
issue_id=str(pk),
|
||||
user_id=request.user.id,
|
||||
)
|
||||
issue_serializer.save()
|
||||
else:
|
||||
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Only project admins can edit intake issue attributes
|
||||
if (project_member and project_member.role > ROLE.MEMBER.value) or is_workspace_admin:
|
||||
serializer = IntakeIssueSerializer(intake_issue, data=request.data, partial=True)
|
||||
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# Update the issue state if the issue is rejected or marked as duplicate
|
||||
if serializer.data["status"] in [-1, 2]:
|
||||
issue = Issue.objects.get(
|
||||
pk=intake_issue.issue_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first()
|
||||
if state is not None:
|
||||
issue.state = state
|
||||
issue.save()
|
||||
if intake_serializer:
|
||||
intake_serializer.save()
|
||||
# create a activity for status change
|
||||
issue_activity.delay(
|
||||
type="intake.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(pk),
|
||||
project_id=str(project_id),
|
||||
current_instance=intake_current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=False,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
intake=str(intake_issue.id),
|
||||
)
|
||||
|
||||
# Update the issue state if it is accepted
|
||||
if serializer.data["status"] in [1]:
|
||||
issue = Issue.objects.get(
|
||||
pk=intake_issue.issue_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# Update the issue state only if it is in triage state
|
||||
if issue.state.is_triage:
|
||||
# Move to default state
|
||||
state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first()
|
||||
if state is not None:
|
||||
issue.state = state
|
||||
issue.save()
|
||||
# create a activity for status change
|
||||
issue_activity.delay(
|
||||
type="intake.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(pk),
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=False,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
intake=(intake_issue.id),
|
||||
)
|
||||
|
||||
intake_issue = (
|
||||
IntakeIssue.objects.select_related("issue")
|
||||
.prefetch_related("issue__labels", "issue__assignees")
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue__labels__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
# Fetch and return the updated intake issue
|
||||
intake_issue = (
|
||||
IntakeIssue.objects.select_related("issue")
|
||||
.prefetch_related("issue__labels", "issue__assignees")
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue__labels__id",
|
||||
distinct=True,
|
||||
filter=Q(~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue__assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue__assignees__id__isnull=True) & Q(issue__issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue__assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue__assignees__id__isnull=True)
|
||||
& Q(issue__issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.get(intake_id=intake_id.id, issue_id=pk, project_id=project_id)
|
||||
)
|
||||
serializer = IntakeIssueDetailSerializer(intake_issue).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
serializer = IntakeIssueDetailSerializer(intake_issue).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.get(intake_id=intake_id.id, issue_id=pk, project_id=project_id)
|
||||
)
|
||||
serializer = IntakeIssueDetailSerializer(intake_issue).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], creator=True, model=Issue)
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
|
||||
@@ -611,6 +611,10 @@ class IssueViewSet(BaseViewSet):
|
||||
def partial_update(self, request, slug, project_id, pk=None):
|
||||
queryset = self.get_queryset()
|
||||
queryset = self.apply_annotations(queryset)
|
||||
|
||||
skip_activity = request.data.pop("skip_activity", False)
|
||||
is_description_update = request.data.get("description_html") is not None
|
||||
|
||||
issue = (
|
||||
queryset.annotate(
|
||||
label_ids=Coalesce(
|
||||
@@ -659,32 +663,36 @@ class IssueViewSet(BaseViewSet):
|
||||
serializer = IssueCreateSerializer(issue, data=request.data, partial=True, context={"project_id": project_id})
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(pk),
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
model_id=str(serializer.data.get("id", None)),
|
||||
requested_data=request.data,
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
# updated issue description version
|
||||
issue_description_version_task.delay(
|
||||
updated_issue=current_instance,
|
||||
issue_id=str(serializer.data.get("id", None)),
|
||||
user_id=request.user.id,
|
||||
)
|
||||
# Check if the update is a migration description update
|
||||
is_migration_description_update = skip_activity and is_description_update
|
||||
# Log all the updates
|
||||
if not is_migration_description_update:
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(pk),
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
model_id=str(serializer.data.get("id", None)),
|
||||
requested_data=request.data,
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
# updated issue description version
|
||||
issue_description_version_task.delay(
|
||||
updated_issue=current_instance,
|
||||
issue_id=str(serializer.data.get("id", None)),
|
||||
user_id=request.user.id,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -39,9 +39,7 @@ class LabelViewSet(BaseViewSet):
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
serializer = LabelSerializer(
|
||||
data=request.data, context={"project_id": project_id}
|
||||
)
|
||||
serializer = LabelSerializer(data=request.data, context={"project_id": project_id})
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -149,14 +149,24 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
def partial_update(self, request, slug, project_id, page_id):
|
||||
try:
|
||||
page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id)
|
||||
page = Page.objects.get(
|
||||
pk=page_id,
|
||||
workspace__slug=slug,
|
||||
projects__id=project_id,
|
||||
project_pages__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
if page.is_locked:
|
||||
return Response({"error": "Page is locked"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
parent = request.data.get("parent", None)
|
||||
if parent:
|
||||
_ = Page.objects.get(pk=parent, workspace__slug=slug, projects__id=project_id)
|
||||
_ = Page.objects.get(
|
||||
pk=parent,
|
||||
workspace__slug=slug,
|
||||
projects__id=project_id,
|
||||
project_pages__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
# Only update access if the page owner is the requesting user
|
||||
if page.access != request.data.get("access", page.access) and page.owned_by_id != request.user.id:
|
||||
@@ -230,14 +240,24 @@ class PageViewSet(BaseViewSet):
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
def lock(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first()
|
||||
page = Page.objects.get(
|
||||
pk=page_id,
|
||||
workspace__slug=slug,
|
||||
projects__id=project_id,
|
||||
project_pages__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
page.is_locked = True
|
||||
page.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def unlock(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first()
|
||||
page = Page.objects.get(
|
||||
pk=page_id,
|
||||
workspace__slug=slug,
|
||||
projects__id=project_id,
|
||||
project_pages__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
page.is_locked = False
|
||||
page.save()
|
||||
@@ -246,7 +266,12 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
def access(self, request, slug, project_id, page_id):
|
||||
access = request.data.get("access", 0)
|
||||
page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first()
|
||||
page = Page.objects.get(
|
||||
pk=page_id,
|
||||
workspace__slug=slug,
|
||||
projects__id=project_id,
|
||||
project_pages__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
# Only update access if the page owner is the requesting user
|
||||
if page.access != request.data.get("access", page.access) and page.owned_by_id != request.user.id:
|
||||
@@ -277,7 +302,12 @@ class PageViewSet(BaseViewSet):
|
||||
return Response(pages, status=status.HTTP_200_OK)
|
||||
|
||||
def archive(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id)
|
||||
page = Page.objects.get(
|
||||
pk=page_id,
|
||||
workspace__slug=slug,
|
||||
projects__id=project_id,
|
||||
project_pages__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
# only the owner or admin can archive the page
|
||||
if (
|
||||
@@ -303,7 +333,12 @@ class PageViewSet(BaseViewSet):
|
||||
return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK)
|
||||
|
||||
def unarchive(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id)
|
||||
page = Page.objects.get(
|
||||
pk=page_id,
|
||||
workspace__slug=slug,
|
||||
projects__id=project_id,
|
||||
project_pages__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
# only the owner or admin can un archive the page
|
||||
if (
|
||||
@@ -327,7 +362,12 @@ class PageViewSet(BaseViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def destroy(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id)
|
||||
page = Page.objects.get(
|
||||
pk=page_id,
|
||||
workspace__slug=slug,
|
||||
projects__id=project_id,
|
||||
project_pages__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
if page.archived_at is None:
|
||||
return Response(
|
||||
@@ -350,7 +390,12 @@ class PageViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
# remove parent from all the children
|
||||
_ = Page.objects.filter(parent_id=page_id, projects__id=project_id, workspace__slug=slug).update(parent=None)
|
||||
_ = Page.objects.filter(
|
||||
parent_id=page_id,
|
||||
projects__id=project_id,
|
||||
workspace__slug=slug,
|
||||
project_pages__deleted_at__isnull=True,
|
||||
).update(parent=None)
|
||||
|
||||
page.delete()
|
||||
# Delete the user favorite page
|
||||
@@ -450,13 +495,13 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
permission_classes = [ProjectPagePermission]
|
||||
|
||||
def retrieve(self, request, slug, project_id, page_id):
|
||||
page = (
|
||||
Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id)
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.first()
|
||||
page = Page.objects.get(
|
||||
Q(owned_by=self.request.user) | Q(access=0),
|
||||
pk=page_id,
|
||||
workspace__slug=slug,
|
||||
projects__id=project_id,
|
||||
project_pages__deleted_at__isnull=True,
|
||||
)
|
||||
if page is None:
|
||||
return Response({"error": "Page not found"}, status=404)
|
||||
binary_data = page.description_binary
|
||||
|
||||
def stream_data():
|
||||
@@ -470,15 +515,14 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
return response
|
||||
|
||||
def partial_update(self, request, slug, project_id, page_id):
|
||||
page = (
|
||||
Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id)
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=0))
|
||||
.first()
|
||||
page = Page.objects.get(
|
||||
Q(owned_by=self.request.user) | Q(access=0),
|
||||
pk=page_id,
|
||||
workspace__slug=slug,
|
||||
projects__id=project_id,
|
||||
project_pages__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
if page is None:
|
||||
return Response({"error": "Page not found"}, status=404)
|
||||
|
||||
if page.is_locked:
|
||||
return Response(
|
||||
{
|
||||
@@ -529,7 +573,12 @@ class PageDuplicateEndpoint(BaseAPIView):
|
||||
permission_classes = [ProjectPagePermission]
|
||||
|
||||
def post(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first()
|
||||
page = Page.objects.get(
|
||||
pk=page_id,
|
||||
workspace__slug=slug,
|
||||
projects__id=project_id,
|
||||
project_pages__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
# check for permission
|
||||
if page.access == Page.PRIVATE_ACCESS and page.owned_by_id != request.user.id:
|
||||
|
||||
@@ -1,43 +1,40 @@
|
||||
# Python imports
|
||||
import boto3
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
import json
|
||||
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseViewSet, BaseAPIView
|
||||
from plane.app.permissions import ROLE, ProjectMemberPermission, allow_permission
|
||||
from plane.app.serializers import (
|
||||
ProjectSerializer,
|
||||
ProjectListSerializer,
|
||||
DeployBoardSerializer,
|
||||
ProjectListSerializer,
|
||||
ProjectSerializer,
|
||||
)
|
||||
|
||||
from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE
|
||||
from plane.app.views.base import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from plane.db.models import (
|
||||
UserFavorite,
|
||||
Intake,
|
||||
DeployBoard,
|
||||
Intake,
|
||||
IssueUserProperty,
|
||||
Project,
|
||||
ProjectIdentifier,
|
||||
ProjectMember,
|
||||
ProjectNetwork,
|
||||
State,
|
||||
DEFAULT_STATES,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.utils.cache import cache_response
|
||||
from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
@@ -209,19 +206,25 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def retrieve(self, request, slug, pk):
|
||||
project = (
|
||||
self.get_queryset()
|
||||
.filter(
|
||||
project_projectmember__member=self.request.user,
|
||||
project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(archived_at__isnull=True)
|
||||
.filter(pk=pk)
|
||||
).first()
|
||||
project = self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk).first()
|
||||
|
||||
if project is None:
|
||||
return Response({"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
member_ids = [str(project_member.member_id) for project_member in project.members_list]
|
||||
|
||||
if str(request.user.id) not in member_ids:
|
||||
if project.network == ProjectNetwork.SECRET.value:
|
||||
return Response(
|
||||
{"error": "You do not have permission"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "You are not a member of this project"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
recent_visited_task.delay(
|
||||
slug=slug,
|
||||
project_id=pk,
|
||||
@@ -264,41 +267,6 @@ class ProjectViewSet(BaseViewSet):
|
||||
user_id=serializer.data["project_lead"],
|
||||
)
|
||||
|
||||
# Default states
|
||||
states = [
|
||||
{
|
||||
"name": "Backlog",
|
||||
"color": "#60646C",
|
||||
"sequence": 15000,
|
||||
"group": "backlog",
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"name": "Todo",
|
||||
"color": "#60646C",
|
||||
"sequence": 25000,
|
||||
"group": "unstarted",
|
||||
},
|
||||
{
|
||||
"name": "In Progress",
|
||||
"color": "#F59E0B",
|
||||
"sequence": 35000,
|
||||
"group": "started",
|
||||
},
|
||||
{
|
||||
"name": "Done",
|
||||
"color": "#46A758",
|
||||
"sequence": 45000,
|
||||
"group": "completed",
|
||||
},
|
||||
{
|
||||
"name": "Cancelled",
|
||||
"color": "#9AA4BC",
|
||||
"sequence": 55000,
|
||||
"group": "cancelled",
|
||||
},
|
||||
]
|
||||
|
||||
State.objects.bulk_create(
|
||||
[
|
||||
State(
|
||||
@@ -311,7 +279,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
default=state.get("default", False),
|
||||
created_by=request.user,
|
||||
)
|
||||
for state in states
|
||||
for state in DEFAULT_STATES
|
||||
]
|
||||
)
|
||||
|
||||
@@ -553,49 +521,6 @@ class ProjectFavoritesViewSet(BaseViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class ProjectPublicCoverImagesEndpoint(BaseAPIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
# Cache the below api for 24 hours
|
||||
@cache_response(60 * 60 * 24, user=False)
|
||||
def get(self, request):
|
||||
files = []
|
||||
if settings.USE_MINIO:
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
)
|
||||
else:
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
)
|
||||
params = {
|
||||
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
|
||||
"Prefix": "static/project-cover/",
|
||||
}
|
||||
|
||||
try:
|
||||
response = s3.list_objects_v2(**params)
|
||||
# Extracting file keys from the response
|
||||
if "Contents" in response:
|
||||
for content in response["Contents"]:
|
||||
if not content["Key"].endswith(
|
||||
"/"
|
||||
): # This line ensures we're only getting files, not "sub-folders"
|
||||
files.append(
|
||||
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
|
||||
)
|
||||
|
||||
return Response(files, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return Response([], status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class DeployBoardViewSet(BaseViewSet):
|
||||
permission_classes = [ProjectMemberPermission]
|
||||
serializer_class = DeployBoardSerializer
|
||||
|
||||
@@ -8,6 +8,7 @@ from plane.app.serializers import (
|
||||
ProjectMemberSerializer,
|
||||
ProjectMemberAdminSerializer,
|
||||
ProjectMemberRoleSerializer,
|
||||
ProjectMemberPreferenceSerializer,
|
||||
)
|
||||
|
||||
from plane.app.permissions import WorkspaceUserPermission
|
||||
@@ -163,6 +164,40 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
serializer = ProjectMemberRoleSerializer(project_members, fields=("id", "member", "role"), many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
requesting_project_member = ProjectMember.objects.get(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
project_member = (
|
||||
ProjectMember.objects.filter(
|
||||
pk=pk,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
)
|
||||
.select_related("project", "member", "workspace")
|
||||
.first()
|
||||
)
|
||||
|
||||
if not project_member:
|
||||
return Response(
|
||||
{"error": "Project member not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if requesting_project_member.role > ROLE.GUEST.value:
|
||||
serializer = ProjectMemberAdminSerializer(project_member)
|
||||
else:
|
||||
serializer = ProjectMemberRoleSerializer(project_member, fields=("id", "member", "role"))
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
project_member = ProjectMember.objects.get(pk=pk, workspace__slug=slug, project_id=project_id, is_active=True)
|
||||
@@ -300,3 +335,32 @@ class UserProjectRolesEndpoint(BaseAPIView):
|
||||
|
||||
project_members = {str(member["project_id"]): member["role"] for member in project_members}
|
||||
return Response(project_members, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ProjectMemberPreferenceEndpoint(BaseAPIView):
|
||||
def get_queryset(self, slug, project_id, member_id):
|
||||
return ProjectMember.objects.get(
|
||||
project_id=project_id,
|
||||
member_id=member_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def patch(self, request, slug, project_id, member_id):
|
||||
project_member = self.get_queryset(slug, project_id, member_id)
|
||||
|
||||
serializer = ProjectMemberPreferenceSerializer(project_member, {"preferences": request.data}, partial=True)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
return Response({"preferences": serializer.data["preferences"]}, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, member_id):
|
||||
project_member = self.get_queryset(slug, project_id, member_id)
|
||||
|
||||
serializer = ProjectMemberPreferenceSerializer(project_member)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -129,9 +129,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
return (
|
||||
cycles.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name", "id", "project_id", "project__identifier", "workspace__slug"
|
||||
)
|
||||
.values("name", "id", "project_id", "project__identifier", "workspace__slug")
|
||||
)
|
||||
|
||||
def filter_modules(self, query, slug, project_id, workspace_search):
|
||||
@@ -155,9 +153,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
return (
|
||||
modules.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name", "id", "project_id", "project__identifier", "workspace__slug"
|
||||
)
|
||||
.values("name", "id", "project_id", "project__identifier", "workspace__slug")
|
||||
)
|
||||
|
||||
def filter_pages(self, query, slug, project_id, workspace_search):
|
||||
@@ -177,9 +173,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
)
|
||||
.annotate(
|
||||
project_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"projects__id", distinct=True, filter=~Q(projects__id=True)
|
||||
),
|
||||
ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
)
|
||||
)
|
||||
@@ -196,20 +190,16 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
if workspace_search == "false" and project_id:
|
||||
project_subquery = ProjectPage.objects.filter(
|
||||
page_id=OuterRef("id"), project_id=project_id
|
||||
).values_list("project_id", flat=True)[:1]
|
||||
project_subquery = ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=project_id).values_list(
|
||||
"project_id", flat=True
|
||||
)[:1]
|
||||
|
||||
pages = pages.annotate(project_id=Subquery(project_subquery)).filter(
|
||||
project_id=project_id
|
||||
)
|
||||
pages = pages.annotate(project_id=Subquery(project_subquery)).filter(project_id=project_id)
|
||||
|
||||
return (
|
||||
pages.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name", "id", "project_ids", "project_identifiers", "workspace__slug"
|
||||
)
|
||||
.values("name", "id", "project_ids", "project_identifiers", "workspace__slug")
|
||||
)
|
||||
|
||||
def filter_views(self, query, slug, project_id, workspace_search):
|
||||
@@ -233,9 +223,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
return (
|
||||
issue_views.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name", "id", "project_id", "project__identifier", "workspace__slug"
|
||||
)
|
||||
.values("name", "id", "project_id", "project__identifier", "workspace__slug")
|
||||
)
|
||||
|
||||
def filter_intakes(self, query, slug, project_id, workspace_search):
|
||||
@@ -294,9 +282,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
|
||||
# Determine which entities to search
|
||||
if entities_param:
|
||||
requested_entities = [
|
||||
e.strip() for e in entities_param.split(",") if e.strip()
|
||||
]
|
||||
requested_entities = [e.strip() for e in entities_param.split(",") if e.strip()]
|
||||
requested_entities = [e for e in requested_entities if e in MODELS_MAPPER]
|
||||
else:
|
||||
requested_entities = list(MODELS_MAPPER.keys())
|
||||
@@ -306,9 +292,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
for entity in requested_entities:
|
||||
func = MODELS_MAPPER.get(entity)
|
||||
if func:
|
||||
results[entity] = func(
|
||||
query or None, slug, project_id, workspace_search
|
||||
)
|
||||
results[entity] = func(query or None, slug, project_id, workspace_search)
|
||||
|
||||
return Response({"results": results}, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -320,7 +304,6 @@ class SearchEndpoint(BaseAPIView):
|
||||
query_types = [qt.strip() for qt in query_types]
|
||||
count = int(request.query_params.get("count", 5))
|
||||
project_id = request.query_params.get("project_id", None)
|
||||
issue_id = request.query_params.get("issue_id", None)
|
||||
|
||||
response_data = {}
|
||||
|
||||
@@ -367,14 +350,10 @@ class SearchEndpoint(BaseAPIView):
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
users = (
|
||||
users
|
||||
.distinct()
|
||||
.values(
|
||||
"member__avatar_url",
|
||||
"member__display_name",
|
||||
"member__id",
|
||||
)
|
||||
users = users.distinct().values(
|
||||
"member__avatar_url",
|
||||
"member__display_name",
|
||||
"member__id",
|
||||
)
|
||||
|
||||
response_data["user_mention"] = list(users[:count])
|
||||
@@ -389,15 +368,12 @@ class SearchEndpoint(BaseAPIView):
|
||||
projects = (
|
||||
Project.objects.filter(
|
||||
q,
|
||||
Q(project_projectmember__member=self.request.user)
|
||||
| Q(network=2),
|
||||
Q(project_projectmember__member=self.request.user) | Q(network=2),
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name", "id", "identifier", "logo_props", "workspace__slug"
|
||||
)[:count]
|
||||
.values("name", "id", "identifier", "logo_props", "workspace__slug")[:count]
|
||||
)
|
||||
response_data["project"] = list(projects)
|
||||
|
||||
@@ -456,20 +432,16 @@ class SearchEndpoint(BaseAPIView):
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
Q(start_date__lte=timezone.now())
|
||||
& Q(end_date__gte=timezone.now()),
|
||||
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(
|
||||
start_date__gt=timezone.now(),
|
||||
then=Value("UPCOMING"),
|
||||
),
|
||||
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
||||
When(
|
||||
end_date__lt=timezone.now(), then=Value("COMPLETED")
|
||||
),
|
||||
When(
|
||||
Q(start_date__isnull=True)
|
||||
& Q(end_date__isnull=True),
|
||||
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
||||
then=Value("DRAFT"),
|
||||
),
|
||||
default=Value("DRAFT"),
|
||||
@@ -587,9 +559,7 @@ class SearchEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.values(
|
||||
"member__avatar_url", "member__display_name", "member__id"
|
||||
)[:count]
|
||||
.values("member__avatar_url", "member__display_name", "member__id")[:count]
|
||||
)
|
||||
response_data["user_mention"] = list(users)
|
||||
|
||||
@@ -603,15 +573,12 @@ class SearchEndpoint(BaseAPIView):
|
||||
projects = (
|
||||
Project.objects.filter(
|
||||
q,
|
||||
Q(project_projectmember__member=self.request.user)
|
||||
| Q(network=2),
|
||||
Q(project_projectmember__member=self.request.user) | Q(network=2),
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name", "id", "identifier", "logo_props", "workspace__slug"
|
||||
)[:count]
|
||||
.values("name", "id", "identifier", "logo_props", "workspace__slug")[:count]
|
||||
)
|
||||
response_data["project"] = list(projects)
|
||||
|
||||
@@ -668,20 +635,16 @@ class SearchEndpoint(BaseAPIView):
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
Q(start_date__lte=timezone.now())
|
||||
& Q(end_date__gte=timezone.now()),
|
||||
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(
|
||||
start_date__gt=timezone.now(),
|
||||
then=Value("UPCOMING"),
|
||||
),
|
||||
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
||||
When(
|
||||
end_date__lt=timezone.now(), then=Value("COMPLETED")
|
||||
),
|
||||
When(
|
||||
Q(start_date__isnull=True)
|
||||
& Q(end_date__isnull=True),
|
||||
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
||||
then=Value("DRAFT"),
|
||||
),
|
||||
default=Value("DRAFT"),
|
||||
|
||||
@@ -10,7 +10,7 @@ from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
from .. import BaseViewSet, BaseAPIView
|
||||
from plane.app.serializers import StateSerializer
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.db.models import State, Issue
|
||||
@@ -127,3 +127,16 @@ class StateViewSet(BaseViewSet):
|
||||
|
||||
state.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class IntakeStateEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id):
|
||||
state = State.triage_objects.filter(workspace__slug=slug, project_id=project_id).first()
|
||||
if not state:
|
||||
return Response(
|
||||
{"error": "Triage state not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
return Response(StateSerializer(state).data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import uuid
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import secrets
|
||||
|
||||
# Django imports
|
||||
@@ -151,13 +149,7 @@ class UserEndpoint(BaseViewSet):
|
||||
# Include user ID to bind the code to the specific user
|
||||
cache_key = f"magic_email_update_{user.id}_{new_email}"
|
||||
## Generate a random token
|
||||
token = (
|
||||
"".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
+ "-"
|
||||
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
+ "-"
|
||||
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
)
|
||||
token = str(secrets.randbelow(900000) + 100000)
|
||||
# Store in cache with 10 minute expiration
|
||||
cache_data = json.dumps({"token": token})
|
||||
cache.set(cache_key, cache_data, timeout=600)
|
||||
@@ -218,7 +210,7 @@ class UserEndpoint(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
return Response(
|
||||
{"error": "Failed to verify code. Please try again."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -50,6 +50,25 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
serializer = WorkSpaceMemberSerializer(workspace_members, fields=("id", "member", "role"), many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def retrieve(self, request, slug, pk):
|
||||
workspace_member = WorkspaceMember.objects.get(member=request.user, workspace__slug=slug, is_active=True)
|
||||
|
||||
try:
|
||||
# Get the specific workspace member by pk
|
||||
member = self.get_queryset().get(pk=pk)
|
||||
except WorkspaceMember.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace member not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if workspace_member.role > ROLE.GUEST.value:
|
||||
serializer = WorkspaceMemberAdminSerializer(member, fields=("id", "member", "role"))
|
||||
else:
|
||||
serializer = WorkSpaceMemberSerializer(member, fields=("id", "member", "role"))
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def partial_update(self, request, slug, pk):
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
|
||||
@@ -249,23 +249,26 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceViewerPermission]
|
||||
|
||||
def patch(self, request, slug):
|
||||
workspace_properties = WorkspaceUserProperties.objects.get(user=request.user, workspace__slug=slug)
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
workspace_properties.filters = request.data.get("filters", workspace_properties.filters)
|
||||
workspace_properties.rich_filters = request.data.get("rich_filters", workspace_properties.rich_filters)
|
||||
workspace_properties.display_filters = request.data.get("display_filters", workspace_properties.display_filters)
|
||||
workspace_properties.display_properties = request.data.get(
|
||||
"display_properties", workspace_properties.display_properties
|
||||
(workspace_properties, _) = WorkspaceUserProperties.objects.get_or_create(
|
||||
user=request.user, workspace_id=workspace.id
|
||||
)
|
||||
workspace_properties.save()
|
||||
|
||||
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
serializer = WorkspaceUserPropertiesSerializer(workspace_properties, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def get(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
(workspace_properties, _) = WorkspaceUserProperties.objects.get_or_create(
|
||||
user=request.user, workspace__slug=slug
|
||||
user=request.user, workspace=workspace
|
||||
)
|
||||
|
||||
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@@ -39,6 +39,16 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
||||
user=request.user,
|
||||
workspace=workspace,
|
||||
sort_order=(65535 + (i * 10000)),
|
||||
is_pinned=(
|
||||
True
|
||||
if key
|
||||
in [
|
||||
WorkspaceUserPreference.UserPreferenceKeys.DRAFTS,
|
||||
WorkspaceUserPreference.UserPreferenceKeys.YOUR_WORK,
|
||||
WorkspaceUserPreference.UserPreferenceKeys.STICKIES,
|
||||
]
|
||||
else False
|
||||
),
|
||||
)
|
||||
for i, key in enumerate(create_preference_keys)
|
||||
],
|
||||
@@ -65,15 +75,23 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def patch(self, request, slug, key):
|
||||
preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug, user=request.user).first()
|
||||
def patch(self, request, slug):
|
||||
for data in request.data:
|
||||
key = data.pop("key", None)
|
||||
if not key:
|
||||
continue
|
||||
|
||||
if preference:
|
||||
serializer = WorkspaceUserPreferenceSerializer(preference, data=request.data, partial=True)
|
||||
preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug).first()
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not preference:
|
||||
continue
|
||||
|
||||
return Response({"detail": "Preference not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if "is_pinned" in data:
|
||||
preference.is_pinned = data["is_pinned"]
|
||||
|
||||
if "sort_order" in data:
|
||||
preference.sort_order = data["sort_order"]
|
||||
|
||||
preference.save(update_fields=["is_pinned", "sort_order"])
|
||||
|
||||
return Response({"message": "Successfully updated"}, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
# Python imports
|
||||
import os
|
||||
import uuid
|
||||
import requests
|
||||
from io import BytesIO
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.core.validators import validate_email
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from zxcvbn import zxcvbn
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Profile, User, WorkspaceMemberInvite
|
||||
from plane.db.models import Profile, User, WorkspaceMemberInvite, FileAsset
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES
|
||||
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
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
class Adapter:
|
||||
@@ -101,6 +105,93 @@ class Adapter:
|
||||
|
||||
return True
|
||||
|
||||
def get_avatar_download_headers(self):
|
||||
return {}
|
||||
|
||||
def download_and_upload_avatar(self, avatar_url, user):
|
||||
"""
|
||||
Downloads avatar from OAuth provider and uploads to our storage.
|
||||
Returns the uploaded file path or None if failed.
|
||||
"""
|
||||
if not avatar_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
headers = self.get_avatar_download_headers()
|
||||
# Download the avatar image
|
||||
response = requests.get(avatar_url, timeout=10, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
# Check content length before downloading
|
||||
content_length = response.headers.get("Content-Length")
|
||||
max_size = settings.DATA_UPLOAD_MAX_MEMORY_SIZE
|
||||
if content_length and int(content_length) > max_size:
|
||||
return None
|
||||
|
||||
# Get content type and determine file extension
|
||||
content_type = response.headers.get("Content-Type", "image/jpeg")
|
||||
extension_map = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
}
|
||||
extension = extension_map.get(content_type)
|
||||
|
||||
if not extension:
|
||||
return None
|
||||
|
||||
# Download with size limit
|
||||
chunks = []
|
||||
total_size = 0
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
total_size += len(chunk)
|
||||
if total_size > max_size:
|
||||
return None
|
||||
chunks.append(chunk)
|
||||
content = b"".join(chunks)
|
||||
file_size = len(content)
|
||||
|
||||
# Generate unique filename
|
||||
filename = f"{uuid.uuid4().hex}-user-avatar.{extension}"
|
||||
|
||||
# Upload to S3/MinIO storage
|
||||
from plane.settings.storage import S3Storage
|
||||
|
||||
storage = S3Storage(request=self.request)
|
||||
|
||||
# Create file-like object
|
||||
file_obj = BytesIO(response.content)
|
||||
file_obj.seek(0)
|
||||
|
||||
# Upload using boto3 directly
|
||||
upload_success = storage.upload_file(file_obj=file_obj, object_name=filename, content_type=content_type)
|
||||
if not upload_success:
|
||||
return None
|
||||
|
||||
# Get storage metadata
|
||||
storage_metadata = storage.get_object_metadata(object_name=filename)
|
||||
|
||||
# Create FileAsset record
|
||||
file_asset = FileAsset.objects.create(
|
||||
attributes={"name": f"{self.provider}-avatar.{extension}", "type": content_type, "size": file_size},
|
||||
asset=filename,
|
||||
size=file_size,
|
||||
user=user,
|
||||
created_by=user,
|
||||
entity_type=FileAsset.EntityTypeContext.USER_AVATAR,
|
||||
is_uploaded=True,
|
||||
storage_metadata=storage_metadata,
|
||||
)
|
||||
|
||||
return file_asset
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
# Return None if upload fails, so original URL can be used as fallback
|
||||
return None
|
||||
|
||||
def save_user_data(self, user):
|
||||
# Update user details
|
||||
user.last_login_medium = self.provider
|
||||
@@ -151,14 +242,23 @@ class Adapter:
|
||||
user.is_password_autoset = False
|
||||
|
||||
# Set user details
|
||||
avatar = self.user_data.get("user", {}).get("avatar", "")
|
||||
first_name = self.user_data.get("user", {}).get("first_name", "")
|
||||
last_name = self.user_data.get("user", {}).get("last_name", "")
|
||||
user.avatar = avatar if avatar else ""
|
||||
user.first_name = first_name if first_name else ""
|
||||
user.last_name = last_name if last_name else ""
|
||||
|
||||
user.save()
|
||||
|
||||
# Download and upload avatar
|
||||
avatar = self.user_data.get("user", {}).get("avatar", "")
|
||||
if avatar:
|
||||
avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user)
|
||||
if avatar_asset:
|
||||
user.avatar_asset = avatar_asset
|
||||
# If avatar upload fails, set the avatar to the original URL
|
||||
else:
|
||||
user.avatar = avatar
|
||||
|
||||
# Create profile
|
||||
Profile.objects.create(user=user)
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Python imports
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import secrets
|
||||
|
||||
|
||||
# Module imports
|
||||
@@ -50,13 +49,7 @@ class MagicCodeProvider(CredentialAdapter):
|
||||
|
||||
def initiate(self):
|
||||
## Generate a random token
|
||||
token = (
|
||||
"".join(random.choices(string.ascii_lowercase, k=4))
|
||||
+ "-"
|
||||
+ "".join(random.choices(string.ascii_lowercase, k=4))
|
||||
+ "-"
|
||||
+ "".join(random.choices(string.ascii_lowercase, k=4))
|
||||
)
|
||||
token = str(secrets.randbelow(900000) + 100000)
|
||||
|
||||
ri = redis_instance()
|
||||
|
||||
|
||||
@@ -101,9 +101,7 @@ class GiteaOAuthProvider(OauthAdapter):
|
||||
else None
|
||||
),
|
||||
"refresh_token_expired_at": (
|
||||
datetime.fromtimestamp(
|
||||
token_response.get("refresh_token_expired_at"), tz=pytz.utc
|
||||
)
|
||||
datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc)
|
||||
if token_response.get("refresh_token_expired_at")
|
||||
else None
|
||||
),
|
||||
@@ -168,4 +166,4 @@ class GiteaOAuthProvider(OauthAdapter):
|
||||
"is_password_autoset": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -37,9 +37,7 @@ class GiteaOauthInitiateEndpoint(View):
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
)
|
||||
url = urljoin(base_host(request=request, is_app=True), "?" + urlencode(params))
|
||||
return HttpResponseRedirect(url)
|
||||
try:
|
||||
state = uuid.uuid4().hex
|
||||
@@ -51,9 +49,7 @@ class GiteaOauthInitiateEndpoint(View):
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
)
|
||||
url = urljoin(base_host(request=request, is_app=True), "?" + urlencode(params))
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
@@ -87,9 +83,7 @@ class GiteaCallbackEndpoint(View):
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
provider = GiteaOAuthProvider(
|
||||
request=request, code=code, callback=post_user_auth_workflow
|
||||
)
|
||||
provider = GiteaOAuthProvider(request=request, code=code, callback=post_user_auth_workflow)
|
||||
user = provider.authenticate()
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
|
||||
@@ -17,6 +17,7 @@ from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
State,
|
||||
StateGroup,
|
||||
Label,
|
||||
Cycle,
|
||||
Module,
|
||||
@@ -264,7 +265,9 @@ def create_issues(workspace, project, user_id, issue_count):
|
||||
Faker.seed(0)
|
||||
|
||||
states = (
|
||||
State.objects.filter(workspace=workspace, project=project).exclude(group="Triage").values_list("id", flat=True)
|
||||
State.objects.filter(workspace=workspace, project=project)
|
||||
.exclude(group=StateGroup.TRIAGE.value)
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
creators = ProjectMember.objects.filter(workspace=workspace, project=project).values_list("member_id", flat=True)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import io
|
||||
import zipfile
|
||||
from typing import List
|
||||
from collections import defaultdict
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
from uuid import UUID
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Python imports
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
# Third party imports
|
||||
from pymongo.collection import Collection
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from plane.settings.mongo import MongoConnection
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.db.models import APIActivityLog
|
||||
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
|
||||
|
||||
def get_mongo_collection() -> Optional[Collection]:
|
||||
"""
|
||||
Returns the MongoDB collection for external API activity logs.
|
||||
"""
|
||||
if not MongoConnection.is_configured():
|
||||
logger.info("MongoDB not configured")
|
||||
return None
|
||||
|
||||
try:
|
||||
return MongoConnection.get_collection("api_activity_logs")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting MongoDB collection: {str(e)}")
|
||||
log_exception(e)
|
||||
return None
|
||||
|
||||
|
||||
def safe_decode_body(content: bytes) -> Optional[str]:
|
||||
"""
|
||||
Safely decodes request/response body content, handling binary data.
|
||||
Returns "[Binary Content]" if the content is binary, or a string representation of the content.
|
||||
Returns None if the content is None or empty.
|
||||
"""
|
||||
# If the content is None, return None
|
||||
if content is None:
|
||||
return None
|
||||
|
||||
# If the content is an empty bytes object, return None
|
||||
if content == b"":
|
||||
return None
|
||||
|
||||
# Check if content is binary by looking for common binary file signatures
|
||||
if content.startswith(b"\x89PNG") or content.startswith(b"\xff\xd8\xff") or content.startswith(b"%PDF"):
|
||||
return "[Binary Content]"
|
||||
|
||||
try:
|
||||
return content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return "[Could not decode content]"
|
||||
|
||||
|
||||
def log_to_mongo(log_document: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Logs the request to MongoDB if available.
|
||||
"""
|
||||
mongo_collection = get_mongo_collection()
|
||||
if mongo_collection is None:
|
||||
logger.error("MongoDB not configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
mongo_collection.insert_one(log_document)
|
||||
return True
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return False
|
||||
|
||||
|
||||
def log_to_postgres(log_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Fallback to logging to PostgreSQL if MongoDB is unavailable.
|
||||
"""
|
||||
try:
|
||||
APIActivityLog.objects.create(**log_data)
|
||||
return True
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return False
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_logs(log_data: Dict[str, Any], mongo_log: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Process logs to save to MongoDB or Postgres based on the configuration
|
||||
"""
|
||||
|
||||
if MongoConnection.is_configured():
|
||||
log_to_mongo(mongo_log)
|
||||
else:
|
||||
log_to_postgres(log_data)
|
||||
@@ -88,7 +88,6 @@ def page_transaction(new_description_html, old_description_html, page_id):
|
||||
|
||||
has_existing_logs = PageLog.objects.filter(page_id=page_id).exists()
|
||||
|
||||
|
||||
# Extract all components in a single pass (optimized)
|
||||
old_components = extract_all_components(old_description_html)
|
||||
new_components = extract_all_components(new_description_html)
|
||||
@@ -125,12 +124,9 @@ def page_transaction(new_description_html, old_description_html, page_id):
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Bulk insert and cleanup
|
||||
if new_transactions:
|
||||
PageLog.objects.bulk_create(
|
||||
new_transactions, batch_size=50, ignore_conflicts=True
|
||||
)
|
||||
PageLog.objects.bulk_create(new_transactions, batch_size=50, ignore_conflicts=True)
|
||||
|
||||
if deleted_transaction_ids:
|
||||
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
|
||||
|
||||
@@ -10,6 +10,7 @@ from datetime import timedelta
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -34,6 +35,8 @@ from plane.db.models import (
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
IssueView,
|
||||
User,
|
||||
BotTypeEnum,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
@@ -61,7 +64,7 @@ def read_seed_file(filename):
|
||||
return None
|
||||
|
||||
|
||||
def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
|
||||
def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int, uuid.UUID]:
|
||||
"""Creates a project and associated members for a workspace.
|
||||
|
||||
Creates a new project using the workspace name and sets up all necessary
|
||||
@@ -69,7 +72,7 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
|
||||
|
||||
Args:
|
||||
workspace: The workspace to create the project in
|
||||
|
||||
bot_user: The bot user to use for creating the project
|
||||
Returns:
|
||||
A mapping of seed project IDs to actual project IDs
|
||||
"""
|
||||
@@ -96,7 +99,7 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
|
||||
workspace=workspace,
|
||||
name=workspace.name, # Use workspace name
|
||||
identifier=project_identifier,
|
||||
created_by_id=workspace.created_by_id,
|
||||
created_by_id=bot_user.id,
|
||||
# Enable all views in seed data
|
||||
cycle_view=True,
|
||||
module_view=True,
|
||||
@@ -111,7 +114,7 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
|
||||
member_id=workspace_member["member_id"],
|
||||
role=workspace_member["role"],
|
||||
workspace_id=workspace.id,
|
||||
created_by_id=workspace.created_by_id,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
for workspace_member in workspace_members
|
||||
]
|
||||
@@ -153,7 +156,7 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
|
||||
"attachment_count": False,
|
||||
"customer_request_count": True,
|
||||
},
|
||||
created_by_id=workspace.created_by_id,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
for workspace_member in workspace_members
|
||||
]
|
||||
@@ -165,13 +168,15 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
|
||||
return projects_map
|
||||
|
||||
|
||||
def create_project_states(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]:
|
||||
def create_project_states(
|
||||
workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User
|
||||
) -> Dict[int, uuid.UUID]:
|
||||
"""Creates states for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
|
||||
bot_user: The bot user to use for creating the states
|
||||
Returns:
|
||||
A mapping of seed state IDs to actual state IDs
|
||||
"""
|
||||
@@ -190,7 +195,7 @@ def create_project_states(workspace: Workspace, project_map: Dict[int, uuid.UUID
|
||||
**state_seed,
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=workspace.created_by_id,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
|
||||
state_map[state_id] = state.id
|
||||
@@ -198,13 +203,15 @@ def create_project_states(workspace: Workspace, project_map: Dict[int, uuid.UUID
|
||||
return state_map
|
||||
|
||||
|
||||
def create_project_labels(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]:
|
||||
def create_project_labels(
|
||||
workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User
|
||||
) -> Dict[int, uuid.UUID]:
|
||||
"""Creates labels for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
|
||||
bot_user: The bot user to use for creating the labels
|
||||
Returns:
|
||||
A mapping of seed label IDs to actual label IDs
|
||||
"""
|
||||
@@ -221,7 +228,7 @@ def create_project_labels(workspace: Workspace, project_map: Dict[int, uuid.UUID
|
||||
**label_seed,
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=workspace.created_by_id,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
label_map[label_id] = label.id
|
||||
|
||||
@@ -236,6 +243,7 @@ def create_project_issues(
|
||||
labels_map: Dict[int, uuid.UUID],
|
||||
cycles_map: Dict[int, uuid.UUID],
|
||||
module_map: Dict[int, uuid.UUID],
|
||||
bot_user: User,
|
||||
) -> None:
|
||||
"""Creates issues and their associated records for each project.
|
||||
|
||||
@@ -273,13 +281,13 @@ def create_project_issues(
|
||||
state_id=states_map[state_id],
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=workspace.created_by_id,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
IssueSequence.objects.create(
|
||||
issue=issue,
|
||||
project_id=project_map[project_id],
|
||||
workspace_id=workspace.id,
|
||||
created_by_id=workspace.created_by_id,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
|
||||
IssueActivity.objects.create(
|
||||
@@ -288,7 +296,7 @@ def create_project_issues(
|
||||
workspace_id=workspace.id,
|
||||
comment="created the issue",
|
||||
verb="created",
|
||||
actor_id=workspace.created_by_id,
|
||||
actor_id=bot_user.id,
|
||||
epoch=time.time(),
|
||||
)
|
||||
|
||||
@@ -299,7 +307,7 @@ def create_project_issues(
|
||||
label_id=labels_map[label_id],
|
||||
project_id=project_map[project_id],
|
||||
workspace_id=workspace.id,
|
||||
created_by_id=workspace.created_by_id,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
|
||||
# Create cycle issues
|
||||
@@ -309,7 +317,7 @@ def create_project_issues(
|
||||
cycle_id=cycles_map[cycle_id],
|
||||
project_id=project_map[project_id],
|
||||
workspace_id=workspace.id,
|
||||
created_by_id=workspace.created_by_id,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
|
||||
# Create module issues
|
||||
@@ -320,19 +328,20 @@ def create_project_issues(
|
||||
module_id=module_map[module_id],
|
||||
project_id=project_map[project_id],
|
||||
workspace_id=workspace.id,
|
||||
created_by_id=workspace.created_by_id,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
|
||||
logger.info(f"Task: workspace_seed_task -> Issue {issue_id} created")
|
||||
return
|
||||
|
||||
|
||||
def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
|
||||
def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> None:
|
||||
"""Creates pages for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
bot_user: The bot user to use for creating the pages
|
||||
"""
|
||||
page_seeds = read_seed_file("pages.json")
|
||||
|
||||
@@ -351,9 +360,9 @@ def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Non
|
||||
description_html=page_seed.get("description_html", "<p></p>"),
|
||||
description_binary=page_seed.get("description_binary", None),
|
||||
description_stripped=page_seed.get("description_stripped", None),
|
||||
created_by_id=workspace.created_by_id,
|
||||
updated_by_id=workspace.created_by_id,
|
||||
owned_by_id=workspace.created_by_id,
|
||||
created_by_id=bot_user.id,
|
||||
updated_by_id=bot_user.id,
|
||||
owned_by_id=bot_user.id,
|
||||
)
|
||||
|
||||
logger.info(f"Task: workspace_seed_task -> Page {page_id} created")
|
||||
@@ -362,19 +371,27 @@ def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Non
|
||||
workspace_id=workspace.id,
|
||||
project_id=project_map[page_seed.get("project_id")],
|
||||
page_id=page.id,
|
||||
created_by_id=workspace.created_by_id,
|
||||
updated_by_id=workspace.created_by_id,
|
||||
created_by_id=bot_user.id,
|
||||
updated_by_id=bot_user.id,
|
||||
)
|
||||
|
||||
logger.info(f"Task: workspace_seed_task -> Project Page {page_id} created")
|
||||
return
|
||||
|
||||
|
||||
def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]:
|
||||
# Create cycles
|
||||
def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> Dict[int, uuid.UUID]:
|
||||
"""Creates cycles for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
bot_user: The bot user to use for creating the cycles
|
||||
Returns:
|
||||
A mapping of seed cycle IDs to actual cycle IDs
|
||||
"""
|
||||
cycle_seeds = read_seed_file("cycles.json")
|
||||
if not cycle_seeds:
|
||||
return
|
||||
return {}
|
||||
|
||||
cycle_map: Dict[int, uuid.UUID] = {}
|
||||
|
||||
@@ -403,8 +420,8 @@ def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Di
|
||||
end_date=end_date,
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=workspace.created_by_id,
|
||||
owned_by_id=workspace.created_by_id,
|
||||
created_by_id=bot_user.id,
|
||||
owned_by_id=bot_user.id,
|
||||
)
|
||||
|
||||
cycle_map[cycle_id] = cycle.id
|
||||
@@ -412,16 +429,17 @@ def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Di
|
||||
return cycle_map
|
||||
|
||||
|
||||
def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
|
||||
def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> None:
|
||||
"""Creates modules for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
bot_user: The bot user to use for creating the modules
|
||||
"""
|
||||
module_seeds = read_seed_file("modules.json")
|
||||
if not module_seeds:
|
||||
return
|
||||
return {}
|
||||
|
||||
module_map: Dict[int, uuid.UUID] = {}
|
||||
|
||||
@@ -438,19 +456,20 @@ def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> N
|
||||
target_date=end_date,
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=workspace.created_by_id,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
module_map[module_id] = module.id
|
||||
logger.info(f"Task: workspace_seed_task -> Module {module_id} created")
|
||||
return module_map
|
||||
|
||||
|
||||
def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
|
||||
def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> None:
|
||||
"""Creates views for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
bot_user: The bot user to use for creating the views
|
||||
"""
|
||||
|
||||
view_seeds = read_seed_file("views.json")
|
||||
@@ -463,8 +482,8 @@ def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Non
|
||||
**view_seed,
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=workspace.created_by_id,
|
||||
owned_by_id=workspace.created_by_id,
|
||||
created_by_id=bot_user.id,
|
||||
owned_by_id=bot_user.id,
|
||||
)
|
||||
|
||||
|
||||
@@ -486,29 +505,42 @@ def workspace_seed(workspace_id: uuid.UUID) -> None:
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(id=workspace_id)
|
||||
|
||||
# Create a bot user for creating all the workspace data
|
||||
bot_user = User.objects.create(
|
||||
username=f"bot_user_{workspace.id}",
|
||||
display_name="Plane",
|
||||
first_name="Plane",
|
||||
last_name="",
|
||||
is_bot=True,
|
||||
bot_type=BotTypeEnum.WORKSPACE_SEED,
|
||||
email=f"bot_user_{workspace.id}@plane.so",
|
||||
password=make_password(uuid.uuid4().hex),
|
||||
is_password_autoset=True,
|
||||
)
|
||||
|
||||
# Create a project with the same name as workspace
|
||||
project_map = create_project_and_member(workspace)
|
||||
project_map = create_project_and_member(workspace, bot_user)
|
||||
|
||||
# Create project states
|
||||
state_map = create_project_states(workspace, project_map)
|
||||
state_map = create_project_states(workspace, project_map, bot_user)
|
||||
|
||||
# Create project labels
|
||||
label_map = create_project_labels(workspace, project_map)
|
||||
label_map = create_project_labels(workspace, project_map, bot_user)
|
||||
|
||||
# Create project cycles
|
||||
cycle_map = create_cycles(workspace, project_map)
|
||||
cycle_map = create_cycles(workspace, project_map, bot_user)
|
||||
|
||||
# Create project modules
|
||||
module_map = create_modules(workspace, project_map)
|
||||
module_map = create_modules(workspace, project_map, bot_user)
|
||||
|
||||
# create project issues
|
||||
create_project_issues(workspace, project_map, state_map, label_map, cycle_map, module_map)
|
||||
create_project_issues(workspace, project_map, state_map, label_map, cycle_map, module_map, bot_user)
|
||||
|
||||
# create project views
|
||||
create_views(workspace, project_map)
|
||||
create_views(workspace, project_map, bot_user)
|
||||
|
||||
# create project pages
|
||||
create_pages(workspace, project_map)
|
||||
create_pages(workspace, project_map, bot_user)
|
||||
|
||||
logger.info(f"Task: workspace_seed_task -> Workspace {workspace_id} seeded successfully")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Generated by Django 4.2.25 on 2025-11-24 06:03
|
||||
|
||||
from django.db import migrations, transaction
|
||||
from django.db.models import OuterRef, Subquery
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger("plane.migrations")
|
||||
|
||||
BATCH_SIZE = 4000
|
||||
|
||||
|
||||
def create_triage_state(apps, _schema_editor):
|
||||
Project = apps.get_model("db", "Project")
|
||||
State = apps.get_model("db", "State")
|
||||
Issue = apps.get_model("db", "Issue")
|
||||
|
||||
|
||||
# 1) Bulk-update existing triage states
|
||||
triage_qs = State.objects.filter(group="triage")
|
||||
projects_with_triage_state = list(triage_qs.values_list("project_id", flat=True))
|
||||
triage_qs.update(
|
||||
name="Triage",
|
||||
color="#4E5355",
|
||||
sequence=65000,
|
||||
default=False,
|
||||
)
|
||||
logger.info(f"Updated {triage_qs.count()} triage states.")
|
||||
|
||||
# 2) Projects that already have a 'Triage' name but not in triage group
|
||||
projects_to_update_qs = (
|
||||
State.objects.exclude(group="triage")
|
||||
.filter(name="Triage")
|
||||
.values_list("project_id", flat=True)
|
||||
)
|
||||
projects_to_update = set(projects_to_update_qs)
|
||||
logger.info(f"Projects to update: {len(projects_to_update)}")
|
||||
|
||||
# 3) Create missing triage states in chunks to avoid memory spike
|
||||
states_to_create = []
|
||||
project_iter = Project.objects.all().values_list("id", "workspace_id").iterator()
|
||||
for proj_id, workspace_id in project_iter:
|
||||
if proj_id in projects_with_triage_state:
|
||||
continue
|
||||
if proj_id in projects_to_update:
|
||||
name = f"Triage-{str(proj_id)[:5]}"
|
||||
else:
|
||||
name = "Triage"
|
||||
states_to_create.append(
|
||||
State(
|
||||
name=name,
|
||||
group="triage",
|
||||
project_id=proj_id,
|
||||
workspace_id=workspace_id,
|
||||
color="#4E5355",
|
||||
sequence=65000,
|
||||
default=False,
|
||||
)
|
||||
)
|
||||
if len(states_to_create) >= BATCH_SIZE:
|
||||
State.objects.bulk_create(states_to_create, batch_size=BATCH_SIZE)
|
||||
states_to_create = []
|
||||
|
||||
if states_to_create:
|
||||
State.objects.bulk_create(states_to_create, batch_size=BATCH_SIZE)
|
||||
|
||||
# 4) Update issues: use deterministic subquery and only update issues that will get a triage state.
|
||||
with transaction.atomic():
|
||||
triage_state_subquery = (
|
||||
State.objects.filter(
|
||||
group="triage",
|
||||
project_id=OuterRef("project_id"),
|
||||
workspace_id=OuterRef("workspace_id"),
|
||||
)
|
||||
.values("id")[:1]
|
||||
)
|
||||
|
||||
updated_count = Issue._default_manager.filter(
|
||||
issue_intake__status__in=[-2, 0],
|
||||
).update(state_id=Subquery(triage_state_subquery))
|
||||
logger.info(f"Updated {updated_count} issues.")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0111_notification_notif_receiver_status_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_triage_state,
|
||||
reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
@@ -188,3 +188,30 @@ class ChangeTrackerMixin:
|
||||
all non-deferred fields).
|
||||
"""
|
||||
return self._original_values
|
||||
|
||||
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""
|
||||
Override save to automatically capture changed fields and reset tracking.
|
||||
|
||||
Before saving, the current changed_fields are captured and stored in
|
||||
_changes_on_save. After saving, the tracked fields are reset so
|
||||
that subsequent saves correctly detect changes relative to the last
|
||||
saved state, not the original load-time state.
|
||||
|
||||
Models that need to access the changed fields after save (e.g., for
|
||||
syncing related models) can use self._changes_on_save.
|
||||
"""
|
||||
self._changes_on_save = self.changed_fields
|
||||
super().save(*args, **kwargs)
|
||||
self._reset_tracked_fields()
|
||||
|
||||
def _reset_tracked_fields(self) -> None:
|
||||
"""
|
||||
Reset the tracked field values to the current state.
|
||||
|
||||
This is called automatically after save() to ensure that subsequent
|
||||
saves correctly detect changes relative to the last saved state,
|
||||
rather than the original load-time state.
|
||||
"""
|
||||
self._original_values = {}
|
||||
self._track_fields()
|
||||
|
||||
@@ -52,12 +52,13 @@ from .project import (
|
||||
ProjectIdentifier,
|
||||
ProjectMember,
|
||||
ProjectMemberInvite,
|
||||
ProjectNetwork,
|
||||
ProjectPublicMember,
|
||||
)
|
||||
from .session import Session
|
||||
from .social_connection import SocialLoginConnection
|
||||
from .state import State
|
||||
from .user import Account, Profile, User
|
||||
from .state import State, StateGroup, DEFAULT_STATES
|
||||
from .user import Account, Profile, User, BotTypeEnum
|
||||
from .view import IssueView
|
||||
from .webhook import Webhook, WebhookLog
|
||||
from .workspace import (
|
||||
|
||||
@@ -19,6 +19,7 @@ from .project import ProjectBaseModel
|
||||
from plane.utils.uuid import convert_uuid_to_integer
|
||||
from .description import Description
|
||||
from plane.db.mixins import ChangeTrackerMixin
|
||||
from .state import StateGroup
|
||||
|
||||
|
||||
def get_default_properties():
|
||||
@@ -97,6 +98,7 @@ class IssueManager(SoftDeletionManager):
|
||||
)
|
||||
.filter(deleted_at__isnull=True)
|
||||
.filter(state__is_triage=False)
|
||||
.exclude(state__group=StateGroup.TRIAGE.value)
|
||||
.exclude(archived_at__isnull=False)
|
||||
.exclude(project__archived_at__isnull=False)
|
||||
.exclude(is_draft=True)
|
||||
@@ -205,39 +207,35 @@ class Issue(ProjectBaseModel):
|
||||
|
||||
if self._state.adding:
|
||||
with transaction.atomic():
|
||||
# Create a lock for this specific project using an advisory lock
|
||||
# Create a lock for this specific project using a transaction-level advisory lock
|
||||
# This ensures only one transaction per project can execute this code at a time
|
||||
# The lock is automatically released when the transaction ends
|
||||
lock_key = convert_uuid_to_integer(self.project.id)
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# Get an exclusive lock using the project ID as the lock key
|
||||
cursor.execute("SELECT pg_advisory_lock(%s)", [lock_key])
|
||||
# Get an exclusive transaction-level lock using the project ID as the lock key
|
||||
cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key])
|
||||
|
||||
try:
|
||||
# Get the last sequence for the project
|
||||
last_sequence = IssueSequence.objects.filter(project=self.project).aggregate(
|
||||
largest=models.Max("sequence")
|
||||
)["largest"]
|
||||
self.sequence_id = last_sequence + 1 if last_sequence else 1
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
largest_sort_order = Issue.objects.filter(project=self.project, state=self.state).aggregate(
|
||||
largest=models.Max("sort_order")
|
||||
)["largest"]
|
||||
if largest_sort_order is not None:
|
||||
self.sort_order = largest_sort_order + 10000
|
||||
# Get the last sequence for the project
|
||||
last_sequence = IssueSequence.objects.filter(project=self.project).aggregate(
|
||||
largest=models.Max("sequence")
|
||||
)["largest"]
|
||||
self.sequence_id = last_sequence + 1 if last_sequence else 1
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
largest_sort_order = Issue.objects.filter(project=self.project, state=self.state).aggregate(
|
||||
largest=models.Max("sort_order")
|
||||
)["largest"]
|
||||
if largest_sort_order is not None:
|
||||
self.sort_order = largest_sort_order + 10000
|
||||
|
||||
super(Issue, self).save(*args, **kwargs)
|
||||
super(Issue, self).save(*args, **kwargs)
|
||||
|
||||
IssueSequence.objects.create(issue=self, sequence=self.sequence_id, project=self.project)
|
||||
finally:
|
||||
# Release the lock
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key])
|
||||
IssueSequence.objects.create(issue=self, sequence=self.sequence_id, project=self.project)
|
||||
else:
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
@@ -511,10 +509,12 @@ class IssueComment(ChangeTrackerMixin, ProjectBaseModel):
|
||||
"comment_json": "description_json",
|
||||
}
|
||||
|
||||
# Use _changes_on_save which is captured by ChangeTrackerMixin.save()
|
||||
# before the tracked fields are reset
|
||||
changed_fields = {
|
||||
desc_field: getattr(self, comment_field)
|
||||
for comment_field, desc_field in field_mapping.items()
|
||||
if self.has_changed(comment_field)
|
||||
if comment_field in self._changes_on_save
|
||||
}
|
||||
|
||||
# Update description only if comment fields changed
|
||||
|
||||
@@ -59,7 +59,7 @@ def get_default_props():
|
||||
|
||||
|
||||
def get_default_preferences():
|
||||
return {"pages": {"block_display": True}}
|
||||
return {"pages": {"block_display": True}, "navigation": {"default_tab": "work_items", "hide_in_more_menu": []}}
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
|
||||
@@ -7,6 +7,71 @@ from django.db.models import Q
|
||||
from .project import ProjectBaseModel
|
||||
|
||||
|
||||
class StateGroup(models.TextChoices):
|
||||
BACKLOG = "backlog", "Backlog"
|
||||
UNSTARTED = "unstarted", "Unstarted"
|
||||
STARTED = "started", "Started"
|
||||
COMPLETED = "completed", "Completed"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
TRIAGE = "triage", "Triage"
|
||||
|
||||
|
||||
# Default states
|
||||
DEFAULT_STATES = [
|
||||
{
|
||||
"name": "Backlog",
|
||||
"color": "#60646C",
|
||||
"sequence": 15000,
|
||||
"group": StateGroup.BACKLOG.value,
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"name": "Todo",
|
||||
"color": "#60646C",
|
||||
"sequence": 25000,
|
||||
"group": StateGroup.UNSTARTED.value,
|
||||
},
|
||||
{
|
||||
"name": "In Progress",
|
||||
"color": "#F59E0B",
|
||||
"sequence": 35000,
|
||||
"group": StateGroup.STARTED.value,
|
||||
},
|
||||
{
|
||||
"name": "Done",
|
||||
"color": "#46A758",
|
||||
"sequence": 45000,
|
||||
"group": StateGroup.COMPLETED.value,
|
||||
},
|
||||
{
|
||||
"name": "Cancelled",
|
||||
"color": "#9AA4BC",
|
||||
"sequence": 55000,
|
||||
"group": StateGroup.CANCELLED.value,
|
||||
},
|
||||
{
|
||||
"name": "Triage",
|
||||
"color": "#4E5355",
|
||||
"sequence": 65000,
|
||||
"group": StateGroup.TRIAGE.value,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class StateManager(models.Manager):
|
||||
"""Default manager - excludes triage states"""
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().exclude(group=StateGroup.TRIAGE.value)
|
||||
|
||||
|
||||
class TriageStateManager(models.Manager):
|
||||
"""Manager for triage states only"""
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(group=StateGroup.TRIAGE.value)
|
||||
|
||||
|
||||
class State(ProjectBaseModel):
|
||||
name = models.CharField(max_length=255, verbose_name="State Name")
|
||||
description = models.TextField(verbose_name="State Description", blank=True)
|
||||
@@ -14,15 +79,8 @@ class State(ProjectBaseModel):
|
||||
slug = models.SlugField(max_length=100, blank=True)
|
||||
sequence = models.FloatField(default=65535)
|
||||
group = models.CharField(
|
||||
choices=(
|
||||
("backlog", "Backlog"),
|
||||
("unstarted", "Unstarted"),
|
||||
("started", "Started"),
|
||||
("completed", "Completed"),
|
||||
("cancelled", "Cancelled"),
|
||||
("triage", "Triage"),
|
||||
),
|
||||
default="backlog",
|
||||
choices=StateGroup.choices,
|
||||
default=StateGroup.BACKLOG,
|
||||
max_length=20,
|
||||
)
|
||||
is_triage = models.BooleanField(default=False)
|
||||
@@ -30,6 +88,10 @@ class State(ProjectBaseModel):
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
objects = StateManager()
|
||||
all_state_objects = models.Manager()
|
||||
triage_objects = TriageStateManager()
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the state"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
@@ -35,6 +35,10 @@ def get_mobile_default_onboarding():
|
||||
}
|
||||
|
||||
|
||||
class BotTypeEnum(models.TextChoices):
|
||||
WORKSPACE_SEED = "WORKSPACE_SEED", "Workspace Seed"
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True)
|
||||
username = models.CharField(max_length=128, unique=True)
|
||||
|
||||
@@ -417,6 +417,7 @@ class WorkspaceUserPreference(BaseModel):
|
||||
DRAFTS = "drafts", "Drafts"
|
||||
YOUR_WORK = "your_work", "Your Work"
|
||||
ARCHIVES = "archives", "Archives"
|
||||
STICKIES = "stickies", "Stickies"
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
|
||||
@@ -134,8 +134,10 @@ class InstanceAdminSignUpEndpoint(View):
|
||||
},
|
||||
)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_admin=True, ),
|
||||
|
||||
base_host(
|
||||
request=request,
|
||||
is_admin=True,
|
||||
),
|
||||
"?" + urlencode(exc.get_error_dict()),
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -175,6 +175,7 @@ class InstanceEndpoint(BaseAPIView):
|
||||
data["app_base_url"] = settings.APP_BASE_URL
|
||||
|
||||
data["instance_changelog_url"] = settings.INSTANCE_CHANGELOG_URL
|
||||
data["is_self_managed"] = settings.IS_SELF_MANAGED
|
||||
|
||||
instance_data = serializer.data
|
||||
instance_data["workspaces_exist"] = Workspace.objects.count() >= 1
|
||||
|
||||
@@ -4,13 +4,15 @@ import time
|
||||
|
||||
# Django imports
|
||||
from django.http import HttpRequest
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.request import Request
|
||||
|
||||
# Module imports
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
from plane.db.models import APIActivityLog
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.bgtasks.logger_task import process_logs
|
||||
|
||||
api_logger = logging.getLogger("plane.api.request")
|
||||
|
||||
@@ -70,6 +72,10 @@ class RequestLoggerMiddleware:
|
||||
|
||||
|
||||
class APITokenLogMiddleware:
|
||||
"""
|
||||
Middleware to log External API requests to MongoDB or PostgreSQL.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
@@ -104,24 +110,41 @@ class APITokenLogMiddleware:
|
||||
def process_request(self, request, response, request_body):
|
||||
api_key_header = "X-Api-Key"
|
||||
api_key = request.headers.get(api_key_header)
|
||||
# If the API key is present, log the request
|
||||
if api_key:
|
||||
try:
|
||||
APIActivityLog.objects.create(
|
||||
token_identifier=api_key,
|
||||
path=request.path,
|
||||
method=request.method,
|
||||
query_params=request.META.get("QUERY_STRING", ""),
|
||||
headers=str(request.headers),
|
||||
body=(self._safe_decode_body(request_body) if request_body else None),
|
||||
response_body=(self._safe_decode_body(response.content) if response.content else None),
|
||||
response_code=response.status_code,
|
||||
ip_address=get_client_ip(request=request),
|
||||
user_agent=request.META.get("HTTP_USER_AGENT", None),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
api_logger.exception(e)
|
||||
# If the token does not exist, you can decide whether to log this as an invalid attempt
|
||||
# If the API key is not present, return
|
||||
if not api_key:
|
||||
return
|
||||
|
||||
try:
|
||||
log_data = {
|
||||
"token_identifier": api_key,
|
||||
"path": request.path,
|
||||
"method": request.method,
|
||||
"query_params": request.META.get("QUERY_STRING", ""),
|
||||
"headers": str(request.headers),
|
||||
"body": self._safe_decode_body(request_body) if request_body else None,
|
||||
"response_body": self._safe_decode_body(response.content) if response.content else None,
|
||||
"response_code": response.status_code,
|
||||
"ip_address": get_client_ip(request=request),
|
||||
"user_agent": request.META.get("HTTP_USER_AGENT", None),
|
||||
}
|
||||
user_id = (
|
||||
str(request.user.id)
|
||||
if getattr(request, "user") and getattr(request.user, "is_authenticated", False)
|
||||
else None
|
||||
)
|
||||
# Additional fields for MongoDB
|
||||
mongo_log = {
|
||||
**log_data,
|
||||
"created_at": timezone.now(),
|
||||
"updated_at": timezone.now(),
|
||||
"created_by": user_id,
|
||||
"updated_by": user_id,
|
||||
}
|
||||
|
||||
process_logs.delay(log_data=log_data, mongo_log=mongo_log)
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
|
||||
return None
|
||||
|
||||
@@ -25,6 +25,9 @@ SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = int(os.environ.get("DEBUG", "0"))
|
||||
|
||||
# Self-hosted mode
|
||||
IS_SELF_MANAGED = True
|
||||
|
||||
# Allowed Hosts
|
||||
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
|
||||
|
||||
@@ -33,6 +36,7 @@ INSTALLED_APPS = [
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.staticfiles",
|
||||
# Inhouse apps
|
||||
"plane.analytics",
|
||||
"plane.app",
|
||||
@@ -55,6 +59,7 @@ INSTALLED_APPS = [
|
||||
MIDDLEWARE = [
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"plane.authentication.middleware.session.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
@@ -69,9 +74,7 @@ MIDDLEWARE = [
|
||||
|
||||
# Rest Framework settings
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
),
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",),
|
||||
"DEFAULT_THROTTLE_CLASSES": ("rest_framework.throttling.AnonRateThrottle",),
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"anon": "30/minute",
|
||||
|
||||
@@ -76,5 +76,10 @@ LOGGING = {
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.migrations": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -86,5 +86,10 @@ LOGGING = {
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.migrations": {
|
||||
"level": "DEBUG" if DEBUG else "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ class S3Storage(S3Boto3Storage):
|
||||
self.aws_region = os.environ.get("AWS_REGION")
|
||||
# Use the AWS_S3_ENDPOINT_URL environment variable for the endpoint URL
|
||||
self.aws_s3_endpoint_url = os.environ.get("AWS_S3_ENDPOINT_URL") or os.environ.get("MINIO_ENDPOINT_URL")
|
||||
# Use the SIGNED_URL_EXPIRATION environment variable for the expiration time (default: 3600 seconds)
|
||||
self.signed_url_expiration = int(os.environ.get("SIGNED_URL_EXPIRATION", "3600"))
|
||||
|
||||
if os.environ.get("USE_MINIO") == "1":
|
||||
# Determine protocol based on environment variable
|
||||
@@ -56,8 +58,10 @@ class S3Storage(S3Boto3Storage):
|
||||
config=boto3.session.Config(signature_version="s3v4"),
|
||||
)
|
||||
|
||||
def generate_presigned_post(self, object_name, file_type, file_size, expiration=3600):
|
||||
def generate_presigned_post(self, object_name, file_type, file_size, expiration=None):
|
||||
"""Generate a presigned URL to upload an S3 object"""
|
||||
if expiration is None:
|
||||
expiration = self.signed_url_expiration
|
||||
fields = {"Content-Type": file_type}
|
||||
|
||||
conditions = [
|
||||
@@ -104,13 +108,15 @@ class S3Storage(S3Boto3Storage):
|
||||
def generate_presigned_url(
|
||||
self,
|
||||
object_name,
|
||||
expiration=3600,
|
||||
expiration=None,
|
||||
http_method="GET",
|
||||
disposition="inline",
|
||||
filename=None,
|
||||
):
|
||||
content_disposition = self._get_content_disposition(disposition, filename)
|
||||
"""Generate a presigned URL to share an S3 object"""
|
||||
if expiration is None:
|
||||
expiration = self.signed_url_expiration
|
||||
content_disposition = self._get_content_disposition(disposition, filename)
|
||||
try:
|
||||
response = self.s3_client.generate_presigned_url(
|
||||
"get_object",
|
||||
@@ -158,3 +164,26 @@ class S3Storage(S3Boto3Storage):
|
||||
return None
|
||||
|
||||
return response
|
||||
|
||||
def upload_file(
|
||||
self,
|
||||
file_obj,
|
||||
object_name: str,
|
||||
content_type: str = None,
|
||||
extra_args: dict = {},
|
||||
) -> bool:
|
||||
"""Upload a file directly to S3"""
|
||||
try:
|
||||
if content_type:
|
||||
extra_args["ContentType"] = content_type
|
||||
|
||||
self.s3_client.upload_fileobj(
|
||||
file_obj,
|
||||
self.aws_storage_bucket_name,
|
||||
object_name,
|
||||
ExtraArgs=extra_args,
|
||||
)
|
||||
return True
|
||||
except ClientError as e:
|
||||
log_exception(e)
|
||||
return False
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user