release: v1.0.0 #7711

This commit is contained in:
sriram veeraghanta 2025-09-10 14:53:52 +05:30 committed by GitHub
commit 63096ceb8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2333 changed files with 48792 additions and 24420 deletions

View file

@ -16,3 +16,48 @@ out/
**/out/ **/out/
dist/ dist/
**/dist/ **/dist/
# Logs
npm-debug.log*
pnpm-debug.log*
.pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS junk
.DS_Store
Thumbs.db
# Editor settings
.vscode
.idea
# Coverage and test output
coverage/
**/coverage/
*.lcov
.junit/
test-results/
# Caches and build artifacts
.cache/
**/.cache/
storybook-static/
*storybook.log
*.tsbuildinfo
# Local env and secrets
.env.local
.env.development.local
.env.test.local
.env.production.local
.secrets
tmp/
temp/
# Database/cache dumps
*.rdb
*.rdb.gz
# Misc
*.pem
*.key

View file

@ -35,6 +35,10 @@ on:
- preview - preview
- canary - canary
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env: env:
TARGET_BRANCH: ${{ github.ref_name }} TARGET_BRANCH: ${{ github.ref_name }}
ARM64_BUILD: ${{ github.event.inputs.arm64 }} ARM64_BUILD: ${{ github.event.inputs.arm64 }}
@ -268,15 +272,14 @@ jobs:
if: ${{ needs.branch_build_setup.outputs.aio_build == 'true' }} if: ${{ needs.branch_build_setup.outputs.aio_build == 'true' }}
name: Build-Push AIO Docker Image name: Build-Push AIO Docker Image
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: [ needs:
branch_build_setup, - branch_build_setup
branch_build_push_admin, - branch_build_push_admin
branch_build_push_web, - branch_build_push_web
branch_build_push_space, - branch_build_push_space
branch_build_push_live, - branch_build_push_live
branch_build_push_api, - branch_build_push_api
branch_build_push_proxy - branch_build_push_proxy
]
steps: steps:
- name: Checkout Files - name: Checkout Files
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -285,7 +288,7 @@ jobs:
id: prepare_aio_assets id: prepare_aio_assets
run: | run: |
cd deployments/aio/community cd deployments/aio/community
if [ "${{ needs.branch_build_setup.outputs.build_type }}" == "Release" ]; then if [ "${{ needs.branch_build_setup.outputs.build_type }}" == "Release" ]; then
aio_version=${{ needs.branch_build_setup.outputs.release_version }} aio_version=${{ needs.branch_build_setup.outputs.release_version }}
else else
@ -324,7 +327,14 @@ jobs:
upload_build_assets: upload_build_assets:
name: Upload Build Assets name: Upload Build Assets
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: [branch_build_setup, branch_build_push_admin, branch_build_push_web, branch_build_push_space, branch_build_push_live, branch_build_push_api, branch_build_push_proxy] needs:
- branch_build_setup
- branch_build_push_admin
- branch_build_push_web
- branch_build_push_space
- branch_build_push_live
- branch_build_push_api
- branch_build_push_proxy
steps: steps:
- name: Checkout Files - name: Checkout Files
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -397,4 +407,3 @@ jobs:
${{ github.workspace }}/deployments/cli/community/docker-compose.yml ${{ github.workspace }}/deployments/cli/community/docker-compose.yml
${{ github.workspace }}/deployments/cli/community/variables.env ${{ github.workspace }}/deployments/cli/community/variables.env
${{ github.workspace }}/deployments/swarm/community/swarm.sh ${{ github.workspace }}/deployments/swarm/community/swarm.sh

View file

@ -1,95 +0,0 @@
name: Build and Lint on Pull Request
on:
workflow_dispatch:
pull_request:
types: ["opened", "synchronize", "ready_for_review"]
jobs:
lint-server:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x" # Specify the Python version you need
- name: Install Pylint
run: python -m pip install ruff
- name: Install Server Dependencies
run: cd apps/server && pip install -r requirements.txt
- name: Lint apps/server
run: ruff check --fix apps/server
lint-admin:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install
- run: yarn lint --filter=admin
lint-space:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install
- run: yarn lint --filter=space
lint-web:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install
- run: yarn lint --filter=web
build-admin:
needs: lint-admin
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install
- run: yarn build --filter=admin
build-space:
needs: lint-space
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install
- run: yarn build --filter=space
build-web:
needs: lint-web
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install
- run: yarn build --filter=web

View file

@ -17,8 +17,6 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with:
node-version: '18'
- name: Get PR Branch version - name: Get PR Branch version
run: echo "PR_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV run: echo "PR_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV

View file

@ -0,0 +1,40 @@
name: Build and lint API
on:
workflow_dispatch:
pull_request:
branches:
- "preview"
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "review_requested"
- "reopened"
paths:
- "apps/api/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-api:
name: Lint API
runs-on: ubuntu-latest
timeout-minutes: 25
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install Pylint
run: python -m pip install ruff
- name: Install API Dependencies
run: cd apps/api && pip install -r requirements.txt
- name: Lint apps/api
run: ruff check --fix apps/api

View file

@ -0,0 +1,53 @@
name: Build and lint web apps
on:
workflow_dispatch:
pull_request:
branches:
- "preview"
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "review_requested"
- "reopened"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-lint:
name: Build and lint web apps
runs-on: ubuntu-latest
timeout-minutes: 25
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 }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 50
filter: blob:none
- name: Set up Node.js
uses: actions/setup-node@v4
- name: Enable Corepack and pnpm
run: corepack enable pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint Affected
run: pnpm turbo run check:lint --affected
- name: Check Affected format
run: pnpm turbo run check:format --affected
- name: Build Affected
run: pnpm turbo run build --affected

10
.gitignore vendored
View file

@ -24,11 +24,13 @@ out/
.DS_Store .DS_Store
*.pem *.pem
.history .history
tsconfig.tsbuildinfo
# Debug # Debug
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log*
.pnpm-debug.log* .pnpm-debug.log*
# Local env files # Local env files
@ -60,6 +62,7 @@ node_modules/
assets/dist/ assets/dist/
npm-debug.log npm-debug.log
yarn-error.log yarn-error.log
pnpm-debug.log
# Editor directories and files # Editor directories and files
.idea .idea
@ -75,10 +78,9 @@ package-lock.json
# lock files # lock files
package-lock.json package-lock.json
pnpm-lock.yaml
pnpm-workspace.yaml
.npmrc
.secrets .secrets
tmp/ tmp/
@ -95,3 +97,5 @@ dev-editor
# Redis # Redis
*.rdb *.rdb
*.rdb.gz *.rdb.gz
storybook-static

34
.npmrc Normal file
View file

@ -0,0 +1,34 @@
# Enforce pnpm workspace behavior and allow Turbo's lifecycle hooks if scripts are disabled
# This repo uses pnpm with workspaces.
# Prefer linking local workspace packages when available
prefer-workspace-packages=true
link-workspace-packages=true
shared-workspace-lockfile=true
# Make peer installs smoother across the monorepo
auto-install-peers=true
strict-peer-dependencies=false
# 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)
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=prettier
public-hoist-pattern[]=typescript
# Reproducible installs across CI and dev
prefer-frozen-lockfile=true
# Prefer resolving to highest versions in monorepo to reduce duplication
resolution-mode=highest
# Speed up native module builds by caching side effects
side-effects-cache=true
# Speed up local dev by reusing local store when possible
prefer-offline=true
# Ensure workspace protocol is used when adding internal deps
save-workspace-protocol=true

View file

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

View file

@ -73,7 +73,7 @@ docker compose -f docker-compose-local.yml up
4. Start web apps: 4. Start web apps:
```bash ```bash
yarn dev pnpm dev
``` ```
5. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin 5. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin

View file

@ -2,11 +2,10 @@
<p align="center"> <p align="center">
<a href="https://plane.so"> <a href="https://plane.so">
<img src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_logo_.webp" alt="Plane Logo" width="70"> <img src="https://media.docs.plane.so/logo/plane_github_readme.png" alt="Plane Logo" width="400">
</a> </a>
</p> </p>
<h1 align="center"><b>Plane</b></h1> <p align="center"><b>Modern project management for all teams</b></p>
<p align="center"><b>Open-source project management that unlocks customer value</b></p>
<p align="center"> <p align="center">
<a href="https://discord.com/invite/A92xrEGCge"> <a href="https://discord.com/invite/A92xrEGCge">
@ -25,14 +24,7 @@
<p> <p>
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank"> <a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_screen.webp" src="https://media.docs.plane.so/GitHub-readme/github-top.webp"
alt="Plane Screens"
width="100%"
/>
</a>
<a href="https://app.plane.so/#gh-dark-mode-only" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_screens_dark_mode.webp"
alt="Plane Screens" alt="Plane Screens"
width="100%" width="100%"
/> />
@ -48,13 +40,13 @@ Meet [Plane](https://plane.so/), an open-source project management tool to track
Getting started with Plane is simple. Choose the setup that works best for you: Getting started with Plane is simple. Choose the setup that works best for you:
- **Plane Cloud** - **Plane Cloud**
Sign up for a free account on [Plane Cloud](https://app.plane.so)—it's the fastest way to get up and running without worrying about infrastructure. Sign up for a free account on [Plane Cloud](https://app.plane.so)—it's the fastest way to get up and running without worrying about infrastructure.
- **Self-host Plane** - **Self-host Plane**
Prefer full control over your data and infrastructure? Install and run Plane on your own servers. Follow our detailed [deployment guides](https://developers.plane.so/self-hosting/overview) to get started. Prefer full control over your data and infrastructure? Install and run Plane on your own servers. Follow our detailed [deployment guides](https://developers.plane.so/self-hosting/overview) to get started.
| Installation methods | Docs link | | Installation methods | Docs link |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://developers.plane.so/self-hosting/methods/docker-compose) | | Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://developers.plane.so/self-hosting/methods/docker-compose) |
| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://developers.plane.so/self-hosting/methods/kubernetes) | | Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://developers.plane.so/self-hosting/methods/kubernetes) |
@ -63,58 +55,58 @@ Prefer full control over your data and infrastructure? Install and run Plane on
## 🌟 Features ## 🌟 Features
- **Issues** - **Issues**
Efficiently create and manage tasks with a robust rich text editor that supports file uploads. Enhance organization and tracking by adding sub-properties and referencing related issues. Efficiently create and manage tasks with a robust rich text editor that supports file uploads. Enhance organization and tracking by adding sub-properties and referencing related issues.
- **Cycles** - **Cycles**
Maintain your teams momentum with Cycles. Track progress effortlessly using burn-down charts and other insightful tools. Maintain your teams momentum with Cycles. Track progress effortlessly using burn-down charts and other insightful tools.
- **Modules** - **Modules**
Simplify complex projects by dividing them into smaller, manageable modules. Simplify complex projects by dividing them into smaller, manageable modules.
- **Views** - **Views**
Customize your workflow by creating filters to display only the most relevant issues. Save and share these views with ease. Customize your workflow by creating filters to display only the most relevant issues. Save and share these views with ease.
- **Pages** - **Pages**
Capture and organize ideas using Plane Pages, complete with AI capabilities and a rich text editor. Format text, insert images, add hyperlinks, or convert your notes into actionable items. Capture and organize ideas using Plane Pages, complete with AI capabilities and a rich text editor. Format text, insert images, add hyperlinks, or convert your notes into actionable items.
- **Analytics** - **Analytics**
Access real-time insights across all your Plane data. Visualize trends, remove blockers, and keep your projects moving forward. Access real-time insights across all your Plane data. Visualize trends, remove blockers, and keep your projects moving forward.
- **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution. - **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution.
## 🛠️ Local development ## 🛠️ Local development
See [CONTRIBUTING](./CONTRIBUTING.md) See [CONTRIBUTING](./CONTRIBUTING.md)
## ⚙️ Built with ## ⚙️ Built with
[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/) [![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/)
[![Django](https://img.shields.io/badge/Django-092E20?style=for-the-badge&logo=django&logoColor=green)](https://www.djangoproject.com/) [![Django](https://img.shields.io/badge/Django-092E20?style=for-the-badge&logo=django&logoColor=green)](https://www.djangoproject.com/)
[![Node JS](https://img.shields.io/badge/node.js-339933?style=for-the-badge&logo=Node.js&logoColor=white)](https://nodejs.org/en) [![Node JS](https://img.shields.io/badge/node.js-339933?style=for-the-badge&logo=Node.js&logoColor=white)](https://nodejs.org/en)
## 📸 Screenshots ## 📸 Screenshots
<p> <p>
<a href="https://plane.so" target="_blank"> <a href="https://plane.so" target="_blank">
<img <img
src="https://ik.imagekit.io/w2okwbtu2/Issues_rNZjrGgFl.png?updatedAt=1709298765880" src="https://media.docs.plane.so/GitHub-readme/github-work-items.webp"
alt="Plane Views" alt="Plane Views"
width="100%" width="100%"
/> />
</a> </a>
</p> </p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://ik.imagekit.io/w2okwbtu2/Cycles_jCDhqmTl9.png?updatedAt=1709298780697"
width="100%"
/>
</a>
</p>
<p> <p>
<a href="https://plane.so" target="_blank"> <a href="https://plane.so" target="_blank">
<img <img
src="https://ik.imagekit.io/w2okwbtu2/Modules_PSCVsbSfI.png?updatedAt=1709298796783" src="https://media.docs.plane.so/GitHub-readme/github-cycles.webp"
width="100%"
/>
</a>
</p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://media.docs.plane.so/GitHub-readme/github-modules.webp"
alt="Plane Cycles and Modules" alt="Plane Cycles and Modules"
width="100%" width="100%"
/> />
@ -123,7 +115,7 @@ See [CONTRIBUTING](./CONTRIBUTING.md)
<p> <p>
<a href="https://plane.so" target="_blank"> <a href="https://plane.so" target="_blank">
<img <img
src="https://ik.imagekit.io/w2okwbtu2/Views_uxXsRatS4.png?updatedAt=1709298834522" src="https://media.docs.plane.so/GitHub-readme/github-views.webp"
alt="Plane Analytics" alt="Plane Analytics"
width="100%" width="100%"
/> />
@ -132,25 +124,16 @@ See [CONTRIBUTING](./CONTRIBUTING.md)
<p> <p>
<a href="https://plane.so" target="_blank"> <a href="https://plane.so" target="_blank">
<img <img
src="https://ik.imagekit.io/w2okwbtu2/Analytics_0o22gLRtp.png?updatedAt=1709298834389" src="https://media.docs.plane.so/GitHub-readme/github-analytics.webp"
alt="Plane Pages" alt="Plane Pages"
width="100%" width="100%"
/> />
</a> </a>
</p> </p>
</p> </p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://ik.imagekit.io/w2okwbtu2/Drive_LlfeY4xn3.png?updatedAt=1709298837917"
alt="Plane Command Menu"
width="100%"
/>
</a>
</p>
</p>
## 📝 Documentation ## 📝 Documentation
Explore Plane's [product documentation](https://docs.plane.so/) and [developer documentation](https://developers.plane.so/) to learn about features, setup, and usage. Explore Plane's [product documentation](https://docs.plane.so/) and [developer documentation](https://developers.plane.so/) to learn about features, setup, and usage.
## ❤️ Community ## ❤️ Community
@ -186,6 +169,6 @@ Please read [CONTRIBUTING.md](https://github.com/makeplane/plane/blob/master/CON
<img src="https://contrib.rocks/image?repo=makeplane/plane" /> <img src="https://contrib.rocks/image?repo=makeplane/plane" />
</a> </a>
## License ## License
This project is licensed under the [GNU Affero General Public License v3.0](https://github.com/makeplane/plane/blob/master/LICENSE.txt). This project is licensed under the [GNU Affero General Public License v3.0](https://github.com/makeplane/plane/blob/master/LICENSE.txt).

12
apps/admin/.eslintignore Normal file
View file

@ -0,0 +1,12 @@
.next/*
out/*
public/*
dist/*
node_modules/*
.turbo/*
.env*
.env
.env.local
.env.development
.env.production
.env.test

View file

@ -1,5 +1,4 @@
module.exports = { module.exports = {
root: true, root: true,
extends: ["@plane/eslint-config/next.js"], extends: ["@plane/eslint-config/next.js"],
parser: "@typescript-eslint/parser",
}; };

View file

@ -2,5 +2,5 @@
.vercel .vercel
.tubro .tubro
out/ out/
dis/ dist/
build/ build/

View file

@ -1,5 +1,11 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS base FROM node:22-alpine AS base
# Setup pnpm package manager with corepack and configure global bin directory for caching
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
# ***************************************************************************** # *****************************************************************************
# STAGE 1: Build the project # STAGE 1: Build the project
# ***************************************************************************** # *****************************************************************************
@ -7,7 +13,8 @@ FROM base AS builder
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
RUN yarn global add turbo ARG TURBO_VERSION=2.5.6
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
COPY . . COPY . .
RUN turbo prune --scope=admin --docker RUN turbo prune --scope=admin --docker
@ -22,11 +29,13 @@ WORKDIR /app
COPY .gitignore .gitignore COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ . COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN yarn install --network-timeout 500000 RUN corepack enable pnpm
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
COPY --from=builder /app/out/full/ . COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json COPY turbo.json turbo.json
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
ARG NEXT_PUBLIC_API_BASE_URL="" ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
@ -49,7 +58,7 @@ ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1 ENV TURBO_TELEMETRY_DISABLED=1
RUN yarn turbo run build --filter=admin RUN pnpm turbo run build --filter=admin
# ***************************************************************************** # *****************************************************************************
# STAGE 3: Copy the project and start it # STAGE 3: Copy the project and start it
@ -91,4 +100,4 @@ ENV TURBO_TELEMETRY_DISABLED=1
EXPOSE 3000 EXPOSE 3000
CMD ["node", "apps/admin/server.js"] CMD ["node", "apps/admin/server.js"]

View file

@ -5,8 +5,8 @@ WORKDIR /app
COPY . . COPY . .
RUN yarn global add turbo RUN corepack enable pnpm && pnpm add -g turbo
RUN yarn install RUN pnpm install
ENV NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" ENV NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
@ -14,4 +14,4 @@ EXPOSE 3000
VOLUME [ "/app/node_modules", "/app/admin/node_modules" ] VOLUME [ "/app/node_modules", "/app/admin/node_modules" ]
CMD ["yarn", "dev", "--filter=admin"] CMD ["pnpm", "dev", "--filter=admin"]

View file

@ -66,9 +66,11 @@ const InstanceGitlabAuthenticationPage = observer(() => {
<ToggleSwitch <ToggleSwitch
value={Boolean(parseInt(enableGitlabConfig))} value={Boolean(parseInt(enableGitlabConfig))}
onChange={() => { onChange={() => {
Boolean(parseInt(enableGitlabConfig)) === true if (Boolean(parseInt(enableGitlabConfig)) === true) {
? updateConfig("IS_GITLAB_ENABLED", "0") updateConfig("IS_GITLAB_ENABLED", "0");
: updateConfig("IS_GITLAB_ENABLED", "1"); } else {
updateConfig("IS_GITLAB_ENABLED", "1");
}
}} }}
size="sm" size="sm"
disabled={isSubmitting || !formattedConfig} disabled={isSubmitting || !formattedConfig}

View file

@ -67,9 +67,11 @@ const InstanceGoogleAuthenticationPage = observer(() => {
<ToggleSwitch <ToggleSwitch
value={Boolean(parseInt(enableGoogleConfig))} value={Boolean(parseInt(enableGoogleConfig))}
onChange={() => { onChange={() => {
Boolean(parseInt(enableGoogleConfig)) === true if (Boolean(parseInt(enableGoogleConfig)) === true) {
? updateConfig("IS_GOOGLE_ENABLED", "0") updateConfig("IS_GOOGLE_ENABLED", "0");
: updateConfig("IS_GOOGLE_ENABLED", "1"); } else {
updateConfig("IS_GOOGLE_ENABLED", "1");
}
}} }}
size="sm" size="sm"
disabled={isSubmitting || !formattedConfig} disabled={isSubmitting || !formattedConfig}

View file

@ -9,7 +9,7 @@ import { useInstance } from "@/hooks/store";
// components // components
import { InstanceEmailForm } from "./email-config-form"; import { InstanceEmailForm } from "./email-config-form";
const InstanceEmailPage = observer(() => { const InstanceEmailPage: React.FC = observer(() => {
// store // store
const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance(); const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance();
@ -29,7 +29,7 @@ const InstanceEmailPage = observer(() => {
message: "Email feature has been disabled", message: "Email feature has been disabled",
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
}); });
} catch (error) { } catch (_error) {
setToast({ setToast({
title: "Error disabling email", title: "Error disabling email",
message: "Failed to disable email feature. Please try again.", message: "Failed to disable email feature. Please try again.",

View file

@ -42,7 +42,7 @@ export const AdminSidebarDropdown = observer(() => {
)} )}
> >
<div className="flex flex-col gap-2.5 pb-2"> <div className="flex flex-col gap-2.5 pb-2">
<span className="px-2 text-custom-sidebar-text-200">{currentUser?.email}</span> <span className="px-2 text-custom-sidebar-text-200 truncate">{currentUser?.email}</span>
</div> </div>
<div className="py-2"> <div className="py-2">
<Menu.Item <Menu.Item

View file

@ -7,7 +7,8 @@ import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
import { Transition } from "@headlessui/react"; import { Transition } from "@headlessui/react";
// plane internal packages // plane internal packages
import { WEB_BASE_URL } from "@plane/constants"; import { WEB_BASE_URL } from "@plane/constants";
import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui"; import { DiscordIcon, GithubIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// hooks // hooks
import { useTheme } from "@/hooks/store"; import { useTheme } from "@/hooks/store";

View file

@ -5,7 +5,8 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
// plane internal packages // plane internal packages
import { Tooltip, WorkspaceIcon } from "@plane/ui"; import { WorkspaceIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// hooks // hooks
import { useTheme } from "@/hooks/store"; import { useTheme } from "@/hooks/store";

View file

@ -0,0 +1,12 @@
"use client";
import Link from "next/link";
import { PlaneLockup } from "@plane/propel/icons";
export const AuthHeader = () => (
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
<Link href="/">
<PlaneLockup height={20} width={95} className="text-custom-text-100" />
</Link>
</div>
);

View file

@ -1,35 +1,9 @@
"use client"; "use client";
import Image from "next/image";
import Link from "next/link";
import { useTheme } from "next-themes";
// logo assets
import PlaneBackgroundPatternDark from "public/auth/background-pattern-dark.svg";
import PlaneBackgroundPattern from "public/auth/background-pattern.svg";
import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png";
import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.png";
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
const { resolvedTheme } = useTheme();
const patternBackground = resolvedTheme === "light" ? PlaneBackgroundPattern : PlaneBackgroundPatternDark;
const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo;
return ( return (
<div className="relative"> <div className="relative z-10 flex flex-col items-center w-screen h-screen overflow-hidden overflow-y-auto pt-6 pb-10 px-8">
<div className="h-screen w-full overflow-hidden overflow-y-auto flex flex-col"> {children}
<div className="container h-[110px] flex-shrink-0 mx-auto px-5 lg:px-0 flex items-center justify-between gap-5 z-50">
<div className="flex items-center gap-x-2 py-10">
<Link href={`/`} className="h-[30px] w-[133px]">
<Image src={logo} alt="Plane logo" />
</Link>
</div>
</div>
<div className="absolute inset-0 z-0">
<Image src={patternBackground} className="w-screen h-full object-cover" alt="Plane background pattern" />
</div>
<div className="relative z-10 flex-grow">{children}</div>
</div>
</div> </div>
); );
} }

View file

@ -2,8 +2,8 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// components // components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { InstanceFailureView } from "@/components/instance/failure"; import { InstanceFailureView } from "@/components/instance/failure";
import { InstanceLoading } from "@/components/instance/loading";
import { InstanceSetupForm } from "@/components/instance/setup-form"; import { InstanceSetupForm } from "@/components/instance/setup-form";
// hooks // hooks
import { useInstance } from "@/hooks/store"; import { useInstance } from "@/hooks/store";
@ -17,46 +17,24 @@ const HomePage = () => {
// if instance is not fetched, show loading // if instance is not fetched, show loading
if (!instance && !error) { if (!instance && !error) {
return ( return (
<div className="relative h-full w-full overflow-y-auto px-6 py-10 mx-auto flex justify-center items-center"> <div className="flex items-center justify-center h-screen w-full">
<InstanceLoading /> <LogoSpinner />
</div> </div>
); );
} }
// if instance fetch fails, show failure view // if instance fetch fails, show failure view
if (error) { if (error) {
return ( return <InstanceFailureView />;
<div className="relative h-full w-full overflow-y-auto px-6 py-10 mx-auto flex justify-center items-center">
<InstanceFailureView />
</div>
);
} }
// if instance is fetched and setup is not done, show setup form // if instance is fetched and setup is not done, show setup form
if (instance && !instance?.is_setup_done) { if (instance && !instance?.is_setup_done) {
return ( return <InstanceSetupForm />;
<div className="relative h-full w-full overflow-y-auto px-6 py-10 mx-auto flex justify-center items-center">
<InstanceSetupForm />
</div>
);
} }
// if instance is fetched and setup is done, show sign in form // if instance is fetched and setup is done, show sign in form
return ( return <InstanceSignInForm />;
<div className="flex-grow container mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 py-10 lg:pt-28 transition-all">
<div className="relative flex flex-col space-y-6">
<div className="text-center space-y-1">
<h3 className="flex gap-4 justify-center text-3xl font-bold text-onboarding-text-100">
Manage your Plane instance
</h3>
<p className="font-medium text-onboarding-text-400">
Configure instance-wide settings to secure your instance
</p>
</div>
<InstanceSignInForm />
</div>
</div>
);
}; };
export default observer(HomePage); export default observer(HomePage);

View file

@ -10,7 +10,9 @@ import { Button, Input, Spinner } from "@plane/ui";
// components // components
import { Banner } from "@/components/common/banner"; import { Banner } from "@/components/common/banner";
// local components // local components
import { FormHeader } from "../../../core/components/instance/form-header";
import { AuthBanner } from "./auth-banner"; import { AuthBanner } from "./auth-banner";
import { AuthHeader } from "./auth-header";
import { authErrorHandler } from "./auth-helpers"; import { authErrorHandler } from "./auth-helpers";
// service initialization // service initialization
@ -101,78 +103,91 @@ export const InstanceSignInForm: FC = () => {
}, [errorCode]); }, [errorCode]);
return ( return (
<form <>
className="space-y-4" <AuthHeader />
method="POST" <div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
action={`${API_BASE_URL}/api/instances/admins/sign-in/`} <div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
onSubmit={() => setIsSubmitting(true)} <FormHeader
onError={() => setIsSubmitting(false)} heading="Manage your Plane instance"
> subHeading="Configure instance-wide settings to secure your instance"
{errorData.type && errorData?.message ? (
<Banner type="error" message={errorData?.message} />
) : (
<>{errorInfo && <AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />}</>
)}
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<div className="w-full space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
Email <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
id="email"
name="email"
type="email"
inputSize="md"
placeholder="name@company.com"
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
autoComplete="on"
autoFocus
/>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
Password <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
id="password"
name="password"
type={showPassword ? "text" : "password"}
inputSize="md"
placeholder="Enter your password"
value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
autoComplete="on"
/> />
{showPassword ? ( <form
<button className="space-y-4"
type="button" method="POST"
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400" action={`${API_BASE_URL}/api/instances/admins/sign-in/`}
onClick={() => setShowPassword(false)} onSubmit={() => setIsSubmitting(true)}
> onError={() => setIsSubmitting(false)}
<EyeOff className="h-4 w-4" /> >
</button> {errorData.type && errorData?.message ? (
) : ( <Banner type="error" message={errorData?.message} />
<button ) : (
type="button" <>
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400" {errorInfo && <AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />}
onClick={() => setShowPassword(true)} </>
> )}
<Eye className="h-4 w-4" /> <input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
</button>
)} <div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="email">
Email <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
id="email"
name="email"
type="email"
inputSize="md"
placeholder="name@company.com"
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
autoComplete="on"
autoFocus
/>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="password">
Password <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
id="password"
name="password"
type={showPassword ? "text" : "password"}
inputSize="md"
placeholder="Enter your password"
value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
autoComplete="on"
/>
{showPassword ? (
<button
type="button"
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => setShowPassword(false)}
>
<EyeOff className="h-4 w-4" />
</button>
) : (
<button
type="button"
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => setShowPassword(true)}
>
<Eye className="h-4 w-4" />
</button>
)}
</div>
</div>
<div className="py-2">
<Button type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Sign in"}
</Button>
</div>
</form>
</div> </div>
</div> </div>
<div className="py-2"> </>
<Button type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Sign in"}
</Button>
</div>
</form>
); );
}; };

View file

@ -7,8 +7,8 @@ import { cn } from "@plane/utils";
type Props = { type Props = {
name: string; name: string;
description: string; description: string;
icon: JSX.Element; icon: React.ReactNode;
config: JSX.Element; config: React.ReactNode;
disabled?: boolean; disabled?: boolean;
withBorder?: boolean; withBorder?: boolean;
unavailable?: boolean; unavailable?: boolean;

View file

@ -25,9 +25,8 @@ export const EmailCodesConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch <ToggleSwitch
value={Boolean(parseInt(enableMagicLogin))} value={Boolean(parseInt(enableMagicLogin))}
onChange={() => { onChange={() => {
Boolean(parseInt(enableMagicLogin)) === true const newEnableMagicLogin = Boolean(parseInt(enableMagicLogin)) === true ? "0" : "1";
? updateConfig("ENABLE_MAGIC_LINK_LOGIN", "0") updateConfig("ENABLE_MAGIC_LINK_LOGIN", newEnableMagicLogin);
: updateConfig("ENABLE_MAGIC_LINK_LOGIN", "1");
}} }}
size="sm" size="sm"
disabled={disabled} disabled={disabled}

View file

@ -35,9 +35,8 @@ export const GithubConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch <ToggleSwitch
value={Boolean(parseInt(enableGithubConfig))} value={Boolean(parseInt(enableGithubConfig))}
onChange={() => { onChange={() => {
Boolean(parseInt(enableGithubConfig)) === true const newEnableGithubConfig = Boolean(parseInt(enableGithubConfig)) === true ? "0" : "1";
? updateConfig("IS_GITHUB_ENABLED", "0") updateConfig("IS_GITHUB_ENABLED", newEnableGithubConfig);
: updateConfig("IS_GITHUB_ENABLED", "1");
}} }}
size="sm" size="sm"
disabled={disabled} disabled={disabled}

View file

@ -35,9 +35,8 @@ export const GitlabConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch <ToggleSwitch
value={Boolean(parseInt(enableGitlabConfig))} value={Boolean(parseInt(enableGitlabConfig))}
onChange={() => { onChange={() => {
Boolean(parseInt(enableGitlabConfig)) === true const newEnableGitlabConfig = Boolean(parseInt(enableGitlabConfig)) === true ? "0" : "1";
? updateConfig("IS_GITLAB_ENABLED", "0") updateConfig("IS_GITLAB_ENABLED", newEnableGitlabConfig);
: updateConfig("IS_GITLAB_ENABLED", "1");
}} }}
size="sm" size="sm"
disabled={disabled} disabled={disabled}

View file

@ -35,9 +35,8 @@ export const GoogleConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch <ToggleSwitch
value={Boolean(parseInt(enableGoogleConfig))} value={Boolean(parseInt(enableGoogleConfig))}
onChange={() => { onChange={() => {
Boolean(parseInt(enableGoogleConfig)) === true const newEnableGoogleConfig = Boolean(parseInt(enableGoogleConfig)) === true ? "0" : "1";
? updateConfig("IS_GOOGLE_ENABLED", "0") updateConfig("IS_GOOGLE_ENABLED", newEnableGoogleConfig);
: updateConfig("IS_GOOGLE_ENABLED", "1");
}} }}
size="sm" size="sm"
disabled={disabled} disabled={disabled}

View file

@ -25,9 +25,8 @@ export const PasswordLoginConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch <ToggleSwitch
value={Boolean(parseInt(enableEmailPassword))} value={Boolean(parseInt(enableEmailPassword))}
onChange={() => { onChange={() => {
Boolean(parseInt(enableEmailPassword)) === true const newEnableEmailPassword = Boolean(parseInt(enableEmailPassword)) === true ? "0" : "1";
? updateConfig("ENABLE_EMAIL_PASSWORD", "0") updateConfig("ENABLE_EMAIL_PASSWORD", newEnableEmailPassword);
: updateConfig("ENABLE_EMAIL_PASSWORD", "1");
}} }}
size="sm" size="sm"
disabled={disabled} disabled={disabled}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { Tooltip } from "@plane/ui"; import { Tooltip } from "@plane/propel/tooltip";
type Props = { type Props = {
label?: string; label?: string;

View file

@ -13,7 +13,7 @@ type Props = {
type: "text" | "password"; type: "text" | "password";
name: string; name: string;
label: string; label: string;
description?: string | JSX.Element; description?: string | React.ReactNode;
placeholder: string; placeholder: string;
error: boolean; error: boolean;
required: boolean; required: boolean;
@ -23,7 +23,7 @@ export type TControllerInputFormField = {
key: string; key: string;
type: "text" | "password"; type: "text" | "password";
label: string; label: string;
description?: string | JSX.Element; description?: string | React.ReactNode;
placeholder: string; placeholder: string;
error: boolean; error: boolean;
required: boolean; required: boolean;

View file

@ -9,14 +9,14 @@ import { Button, TOAST_TYPE, setToast } from "@plane/ui";
type Props = { type Props = {
label: string; label: string;
url: string; url: string;
description: string | JSX.Element; description: string | React.ReactNode;
}; };
export type TCopyField = { export type TCopyField = {
key: string; key: string;
label: string; label: string;
url: string; url: string;
description: string | JSX.Element; description: string | React.ReactNode;
}; };
export const CopyField: React.FC<Props> = (props) => { export const CopyField: React.FC<Props> = (props) => {

View file

@ -7,11 +7,11 @@ import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif";
export const LogoSpinner = () => { export const LogoSpinner = () => {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight; const logoSrc = resolvedTheme === "dark" ? LogoSpinnerLight : LogoSpinnerDark;
return ( return (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<Image src={logoSrc} alt="logo" className="w-[82px] h-[82px] mr-2" priority={false} /> <Image src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
</div> </div>
); );
}; };

View file

@ -1,89 +0,0 @@
"use client";
import { FC, useMemo } from "react";
// plane internal packages
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { cn, getPasswordStrength } from "@plane/utils";
type TPasswordStrengthMeter = {
password: string;
isFocused?: boolean;
};
export const PasswordStrengthMeter: FC<TPasswordStrengthMeter> = (props) => {
const { password, isFocused = false } = props;
// derived values
const strength = useMemo(() => getPasswordStrength(password), [password]);
const strengthBars = useMemo(() => {
switch (strength) {
case E_PASSWORD_STRENGTH.EMPTY: {
return {
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Please enter your password.",
textColor: "text-custom-text-100",
};
}
case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: {
return {
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Password length should me more than 8 characters.",
textColor: "text-red-500",
};
}
case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: {
return {
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Password is weak.",
textColor: "text-red-500",
};
}
case E_PASSWORD_STRENGTH.STRENGTH_VALID: {
return {
bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`],
text: "Password is strong.",
textColor: "text-green-500",
};
}
default: {
return {
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Please enter your password.",
textColor: "text-custom-text-100",
};
}
}
}, [strength]);
const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true;
if (!isPasswordMeterVisible) return <></>;
return (
<div className="w-full space-y-2 pt-2">
<div className="space-y-1.5">
<div className="relative flex items-center gap-2">
{strengthBars?.bars.map((color, index) => (
<div key={`${color}-${index}`} className={cn("w-full h-1 rounded-full", color)} />
))}
</div>
<div className={cn(`text-xs font-medium text-custom-text-100`, strengthBars?.textColor)}>
{strengthBars?.text}
</div>
</div>
{/* <div className="relative flex flex-wrap gap-x-4 gap-y-2">
{PASSWORD_CRITERIA.map((criteria) => (
<div
key={criteria.key}
className={cn(
"relative flex items-center gap-1 text-xs",
criteria.isCriteriaValid(password) ? `text-green-500/70` : "text-custom-text-300"
)}
>
<CircleCheck width={14} height={14} />
{criteria.label}
</div>
))}
</div> */}
</div>
);
};

View file

@ -1,13 +1,15 @@
"use client"; "use client";
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react";
import Image from "next/image"; import Image from "next/image";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// assets // assets
import { AuthHeader } from "@/app/(all)/(home)/auth-header";
import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg"; import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg";
import InstanceFailureImage from "@/public/instance/instance-failure.svg"; import InstanceFailureImage from "@/public/instance/instance-failure.svg";
export const InstanceFailureView: FC = () => { export const InstanceFailureView: FC = observer(() => {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage; const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage;
@ -17,22 +19,24 @@ export const InstanceFailureView: FC = () => {
}; };
return ( return (
<div className="h-full w-full relative container px-5 mx-auto flex justify-center items-center"> <>
<div className="w-auto max-w-2xl relative space-y-8 py-10"> <AuthHeader />
<div className="relative flex flex-col justify-center items-center space-y-4"> <div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
<Image src={instanceImage} alt="Plane Logo" /> <div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
<h3 className="font-medium text-2xl text-white ">Unable to fetch instance details.</h3> <div className="relative flex flex-col justify-center items-center space-y-4">
<p className="font-medium text-base text-center"> <Image src={instanceImage} alt="Plane Logo" />
We were unable to fetch the details of the instance. <br /> <h3 className="font-medium text-2xl text-white text-center">Unable to fetch instance details.</h3>
Fret not, it might just be a connectivity issue. <p className="font-medium text-base text-center">
</p> We were unable to fetch the details of the instance. Fret not, it might just be a connectivity issue.
</div> </p>
<div className="flex justify-center"> </div>
<Button size="md" onClick={handleRetry}> <div className="flex justify-center">
Retry <Button size="md" onClick={handleRetry}>
</Button> Retry
</Button>
</div>
</div> </div>
</div> </div>
</div> </>
); );
}; });

View file

@ -0,0 +1,8 @@
"use client";
export const FormHeader = ({ heading, subHeading }: { heading: string; subHeading: string }) => (
<div className="flex flex-col gap-1">
<span className="text-2xl font-semibold text-custom-text-100 leading-7">{heading}</span>
<span className="text-lg font-semibold text-custom-text-400 leading-7">{subHeading}</span>
</div>
);

View file

@ -13,7 +13,7 @@ export const InstanceNotReady: FC = () => (
<div className="relative flex flex-col justify-center items-center space-y-4"> <div className="relative flex flex-col justify-center items-center space-y-4">
<h1 className="text-3xl font-bold pb-3">Welcome aboard Plane!</h1> <h1 className="text-3xl font-bold pb-3">Welcome aboard Plane!</h1>
<Image src={PlaneTakeOffImage} alt="Plane Logo" /> <Image src={PlaneTakeOffImage} alt="Plane Logo" />
<p className="font-medium text-base text-onboarding-text-400"> <p className="font-medium text-base text-custom-text-400">
Get started by setting up your instance and workspace Get started by setting up your instance and workspace
</p> </p>
</div> </div>

View file

@ -6,16 +6,12 @@ import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif";
export const InstanceLoading = () => { export const InstanceLoading = () => {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight;
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerLight : LogoSpinnerDark;
return ( return (
<div className="h-full w-full relative container px-5 mx-auto flex justify-center items-center"> <div className="flex items-center justify-center">
<div className="w-auto max-w-2xl relative space-y-8 py-10"> <Image src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
<div className="relative flex flex-col justify-center items-center space-y-4">
<Image src={logoSrc} alt="logo" className="w-[82px] h-[82px] mr-2" priority={false} />
<h3 className="font-medium text-2xl text-white ">Fetching instance details...</h3>
</div>
</div>
</div> </div>
); );
}; };

View file

@ -7,11 +7,12 @@ import { Eye, EyeOff } from "lucide-react";
// plane internal packages // plane internal packages
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants"; import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
import { AuthService } from "@plane/services"; import { AuthService } from "@plane/services";
import { Button, Checkbox, Input, Spinner } from "@plane/ui"; import { Button, Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils"; import { getPasswordStrength } from "@plane/utils";
// components // components
import { AuthHeader } from "@/app/(all)/(home)/auth-header";
import { Banner } from "@/components/common/banner"; import { Banner } from "@/components/common/banner";
import { PasswordStrengthMeter } from "@/components/common/password-strength-meter"; import { FormHeader } from "@/components/instance/form-header";
// service initialization // service initialization
const authService = new AuthService(); const authService = new AuthService();
@ -132,227 +133,221 @@ export const InstanceSetupForm: FC = (props) => {
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
return ( return (
<div className="max-w-lg lg:max-w-md w-full"> <>
<div className="relative flex flex-col space-y-6"> <AuthHeader />
<div className="text-center space-y-1"> <div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
<h3 className="flex gap-4 justify-center text-3xl font-bold text-onboarding-text-100"> <div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
Setup your Plane Instance <FormHeader
</h3> heading="Setup your Plane Instance"
<p className="font-medium text-onboarding-text-400"> subHeading="Post setup you will be able to manage this Plane instance."
Post setup you will be able to manage this Plane instance. />
</p> {errorData.type &&
errorData?.message &&
![EErrorCodes.INVALID_EMAIL, EErrorCodes.INVALID_PASSWORD].includes(errorData.type) && (
<Banner type="error" message={errorData?.message} />
)}
<form
className="space-y-4"
method="POST"
action={`${API_BASE_URL}/api/instances/admins/sign-up/`}
onSubmit={() => setIsSubmitting(true)}
onError={() => setIsSubmitting(false)}
>
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<input type="hidden" name="is_telemetry_enabled" value={formData.is_telemetry_enabled ? "True" : "False"} />
<div className="flex flex-col sm:flex-row items-center gap-4">
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="first_name">
First name <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
id="first_name"
name="first_name"
type="text"
inputSize="md"
placeholder="Wilber"
value={formData.first_name}
onChange={(e) => handleFormChange("first_name", e.target.value)}
autoComplete="on"
autoFocus
/>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="last_name">
Last name <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
id="last_name"
name="last_name"
type="text"
inputSize="md"
placeholder="Wright"
value={formData.last_name}
onChange={(e) => handleFormChange("last_name", e.target.value)}
autoComplete="on"
/>
</div>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="email">
Email <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
id="email"
name="email"
type="email"
inputSize="md"
placeholder="name@company.com"
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false}
autoComplete="on"
/>
{errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && (
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
)}
</div>
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="company_name">
Company name <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
id="company_name"
name="company_name"
type="text"
inputSize="md"
placeholder="Company name"
value={formData.company_name}
onChange={(e) => handleFormChange("company_name", e.target.value)}
/>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="password">
Set a password <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
id="password"
name="password"
type={showPassword.password ? "text" : "password"}
inputSize="md"
placeholder="New password..."
value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="on"
/>
{showPassword.password ? (
<button
type="button"
tabIndex={-1}
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => handleShowPassword("password")}
>
<EyeOff className="h-4 w-4" />
</button>
) : (
<button
type="button"
tabIndex={-1}
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => handleShowPassword("password")}
>
<Eye className="h-4 w-4" />
</button>
)}
</div>
{errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && (
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
)}
<PasswordStrengthIndicator password={formData.password} isFocused={isPasswordInputFocused} />
</div>
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="confirm_password">
Confirm password <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
type={showPassword.retypePassword ? "text" : "password"}
id="confirm_password"
name="confirm_password"
inputSize="md"
value={formData.confirm_password}
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
placeholder="Confirm password"
className="w-full border border-custom-border-100 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
/>
{showPassword.retypePassword ? (
<button
type="button"
tabIndex={-1}
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => handleShowPassword("retypePassword")}
>
<EyeOff className="h-4 w-4" />
</button>
) : (
<button
type="button"
tabIndex={-1}
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => handleShowPassword("retypePassword")}
>
<Eye className="h-4 w-4" />
</button>
)}
</div>
{!!formData.confirm_password &&
formData.password !== formData.confirm_password &&
renderPasswordMatchError && <span className="text-sm text-red-500">Passwords don{"'"}t match</span>}
</div>
<div className="relative flex gap-2">
<div>
<Checkbox
className="w-4 h-4"
iconClassName="w-3 h-3"
id="is_telemetry_enabled"
onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)}
checked={formData.is_telemetry_enabled}
/>
</div>
<label className="text-sm text-custom-text-300 font-medium cursor-pointer" htmlFor="is_telemetry_enabled">
Allow Plane to anonymously collect usage events.{" "}
<a
tabIndex={-1}
href="https://developers.plane.so/self-hosting/telemetry"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-blue-500 hover:text-blue-600 flex-shrink-0"
>
See More
</a>
</label>
</div>
<div className="py-2">
<Button type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"}
</Button>
</div>
</form>
</div> </div>
{errorData.type &&
errorData?.message &&
![EErrorCodes.INVALID_EMAIL, EErrorCodes.INVALID_PASSWORD].includes(errorData.type) && (
<Banner type="error" message={errorData?.message} />
)}
<form
className="space-y-4"
method="POST"
action={`${API_BASE_URL}/api/instances/admins/sign-up/`}
onSubmit={() => setIsSubmitting(true)}
onError={() => setIsSubmitting(false)}
>
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<input type="hidden" name="is_telemetry_enabled" value={formData.is_telemetry_enabled ? "True" : "False"} />
<div className="flex flex-col sm:flex-row items-center gap-4">
<div className="w-full space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="first_name">
First name <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
id="first_name"
name="first_name"
type="text"
inputSize="md"
placeholder="Wilber"
value={formData.first_name}
onChange={(e) => handleFormChange("first_name", e.target.value)}
autoComplete="on"
autoFocus
/>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="last_name">
Last name <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
id="last_name"
name="last_name"
type="text"
inputSize="md"
placeholder="Wright"
value={formData.last_name}
onChange={(e) => handleFormChange("last_name", e.target.value)}
autoComplete="on"
/>
</div>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
Email <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
id="email"
name="email"
type="email"
inputSize="md"
placeholder="name@company.com"
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false}
autoComplete="on"
/>
{errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && (
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
)}
</div>
<div className="w-full space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="company_name">
Company name <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
id="company_name"
name="company_name"
type="text"
inputSize="md"
placeholder="Company name"
value={formData.company_name}
onChange={(e) => handleFormChange("company_name", e.target.value)}
/>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
Set a password <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
id="password"
name="password"
type={showPassword.password ? "text" : "password"}
inputSize="md"
placeholder="New password..."
value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="on"
/>
{showPassword.password ? (
<button
type="button"
tabIndex={-1}
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => handleShowPassword("password")}
>
<EyeOff className="h-4 w-4" />
</button>
) : (
<button
type="button"
tabIndex={-1}
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => handleShowPassword("password")}
>
<Eye className="h-4 w-4" />
</button>
)}
</div>
{errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && (
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
)}
<PasswordStrengthMeter password={formData.password} isFocused={isPasswordInputFocused} />
</div>
<div className="w-full space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="confirm_password">
Confirm password <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
type={showPassword.retypePassword ? "text" : "password"}
id="confirm_password"
name="confirm_password"
inputSize="md"
value={formData.confirm_password}
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
placeholder="Confirm password"
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
/>
{showPassword.retypePassword ? (
<button
type="button"
tabIndex={-1}
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => handleShowPassword("retypePassword")}
>
<EyeOff className="h-4 w-4" />
</button>
) : (
<button
type="button"
tabIndex={-1}
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => handleShowPassword("retypePassword")}
>
<Eye className="h-4 w-4" />
</button>
)}
</div>
{!!formData.confirm_password &&
formData.password !== formData.confirm_password &&
renderPasswordMatchError && <span className="text-sm text-red-500">Passwords don{"'"}t match</span>}
</div>
<div className="relative flex items-center pt-2 gap-2">
<div>
<Checkbox
className="w-4 h-4"
iconClassName="w-3 h-3"
id="is_telemetry_enabled"
onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)}
checked={formData.is_telemetry_enabled}
/>
</div>
<label
className="text-sm text-onboarding-text-300 font-medium cursor-pointer"
htmlFor="is_telemetry_enabled"
>
Allow Plane to anonymously collect usage events.
</label>
<a
tabIndex={-1}
href="https://developers.plane.so/self-hosting/telemetry"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-blue-500 hover:text-blue-600"
>
See More
</a>
</div>
<div className="py-2">
<Button type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"}
</Button>
</div>
</form>
</div> </div>
</div> </>
); );
}; };

View file

@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
// plane internal packages // plane internal packages
import { WEB_BASE_URL } from "@plane/constants"; import { WEB_BASE_URL } from "@plane/constants";
import { Tooltip } from "@plane/ui"; import { Tooltip } from "@plane/propel/tooltip";
import { getFileURL } from "@plane/utils"; import { getFileURL } from "@plane/utils";
// hooks // hooks
import { useWorkspace } from "@/hooks/store"; import { useWorkspace } from "@/hooks/store";

View file

@ -209,7 +209,7 @@ export class InstanceStore implements IInstanceStore {
}); });
}); });
await this.instanceService.disableEmail(); await this.instanceService.disableEmail();
} catch (error) { } catch (_error) {
console.error("Error disabling the email"); console.error("Error disabling the email");
this.instanceConfigurations = instanceConfigurations; this.instanceConfigurations = instanceConfigurations;
} }

View file

@ -1,7 +1,7 @@
{ {
"name": "admin", "name": "admin",
"description": "Admin UI for Plane", "description": "Admin UI for Plane",
"version": "0.28.0", "version": "1.0.0",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -10,7 +10,7 @@
"preview": "next build && next start", "preview": "next build && next start",
"start": "next start", "start": "next start",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist", "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist",
"check:lint": "eslint . --max-warnings 0", "check:lint": "eslint . --max-warnings 19",
"check:types": "tsc --noEmit", "check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"", "check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix", "fix:lint": "eslint . --fix",
@ -18,40 +18,38 @@
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^1.7.19", "@headlessui/react": "^1.7.19",
"@plane/constants": "*", "@plane/constants": "workspace:*",
"@plane/hooks": "*", "@plane/hooks": "workspace:*",
"@plane/propel": "*", "@plane/propel": "workspace:*",
"@plane/services": "*", "@plane/services": "workspace:*",
"@plane/types": "*", "@plane/types": "workspace:*",
"@plane/ui": "*", "@plane/ui": "workspace:*",
"@plane/utils": "*", "@plane/utils": "workspace:*",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14", "autoprefixer": "10.4.14",
"axios": "1.11.0", "axios": "catalog:",
"lodash": "^4.17.21", "lodash": "catalog:",
"lucide-react": "^0.469.0", "lucide-react": "catalog:",
"mobx": "^6.12.0", "mobx": "catalog:",
"mobx-react": "^9.1.1", "mobx-react": "catalog:",
"next": "14.2.30", "next": "catalog:",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"react": "^18.3.1", "react": "catalog:",
"react-dom": "^18.3.1", "react-dom": "catalog:",
"react-hook-form": "7.51.5", "react-hook-form": "7.51.5",
"swr": "^2.2.4", "sharp": "catalog:",
"uuid": "^9.0.1", "swr": "catalog:",
"zxcvbn": "^4.4.2" "uuid": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@plane/eslint-config": "*", "@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "*", "@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "*", "@plane/typescript-config": "workspace:*",
"@types/lodash": "catalog:",
"@types/node": "18.16.1", "@types/node": "18.16.1",
"@types/react": "^18.3.11", "@types/react": "catalog:",
"@types/react-dom": "^18.2.18", "@types/react-dom": "catalog:",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.4", "typescript": "catalog:"
"typescript": "5.8.3"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 466 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 761 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 919 B

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -2,8 +2,8 @@
"name": "", "name": "",
"short_name": "", "short_name": "",
"icons": [ "icons": [
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } { "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
], ],
"theme_color": "#ffffff", "theme_color": "#ffffff",
"background_color": "#ffffff", "background_color": "#ffffff",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 954 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 418 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -24,24 +24,24 @@
:root { :root {
color-scheme: light !important; color-scheme: light !important;
--color-primary-10: 236, 241, 255; --color-primary-10: 229, 243, 250;
--color-primary-20: 217, 228, 255; --color-primary-20: 216, 237, 248;
--color-primary-30: 197, 214, 255; --color-primary-30: 199, 229, 244;
--color-primary-40: 178, 200, 255; --color-primary-40: 169, 214, 239;
--color-primary-50: 159, 187, 255; --color-primary-50: 144, 202, 234;
--color-primary-60: 140, 173, 255; --color-primary-60: 109, 186, 227;
--color-primary-70: 121, 159, 255; --color-primary-70: 75, 170, 221;
--color-primary-80: 101, 145, 255; --color-primary-80: 41, 154, 214;
--color-primary-90: 82, 132, 255; --color-primary-90: 34, 129, 180;
--color-primary-100: 63, 118, 255; --color-primary-100: 0, 99, 153;
--color-primary-200: 57, 106, 230; --color-primary-200: 0, 92, 143;
--color-primary-300: 50, 94, 204; --color-primary-300: 0, 86, 133;
--color-primary-400: 44, 83, 179; --color-primary-400: 0, 77, 117;
--color-primary-500: 38, 71, 153; --color-primary-500: 0, 66, 102;
--color-primary-600: 32, 59, 128; --color-primary-600: 0, 53, 82;
--color-primary-700: 25, 47, 102; --color-primary-700: 0, 43, 66;
--color-primary-800: 19, 35, 76; --color-primary-800: 0, 33, 51;
--color-primary-900: 13, 24, 51; --color-primary-900: 0, 23, 36;
--color-background-100: 255, 255, 255; /* primary bg */ --color-background-100: 255, 255, 255; /* primary bg */
--color-background-90: 247, 247, 247; /* secondary bg */ --color-background-90: 247, 247, 247; /* secondary bg */
@ -135,28 +135,6 @@
--color-border-300: 212, 212, 212; /* strong border- 1 */ --color-border-300: 212, 212, 212; /* strong border- 1 */
--color-border-400: 185, 185, 185; /* strong border- 2 */ --color-border-400: 185, 185, 185; /* strong border- 2 */
/* onboarding colors */
--gradient-onboarding-100: linear-gradient(106deg, #f2f6ff 29.8%, #e1eaff 99.34%);
--gradient-onboarding-200: linear-gradient(129deg, rgba(255, 255, 255, 0) -22.23%, rgba(255, 255, 255, 0.8) 62.98%);
--gradient-onboarding-300: linear-gradient(164deg, #fff 4.25%, rgba(255, 255, 255, 0.06) 93.5%);
--gradient-onboarding-400: linear-gradient(129deg, rgba(255, 255, 255, 0) -22.23%, rgba(255, 255, 255, 0.8) 62.98%);
--color-onboarding-text-100: 23, 23, 23;
--color-onboarding-text-200: 58, 58, 58;
--color-onboarding-text-300: 82, 82, 82;
--color-onboarding-text-400: 163, 163, 163;
--color-onboarding-background-100: 236, 241, 255;
--color-onboarding-background-200: 255, 255, 255;
--color-onboarding-background-300: 236, 241, 255;
--color-onboarding-background-400: 177, 206, 250;
--color-onboarding-border-100: 229, 229, 229;
--color-onboarding-border-200: 217, 228, 255;
--color-onboarding-border-300: 229, 229, 229, 0.5;
--color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(126, 139, 171, 0.1);
/* toast theme */ /* toast theme */
--color-toast-success-text: 62, 155, 79; --color-toast-success-text: 62, 155, 79;
--color-toast-error-text: 220, 62, 66; --color-toast-error-text: 220, 62, 66;
@ -197,6 +175,25 @@
[data-theme="dark-contrast"] { [data-theme="dark-contrast"] {
color-scheme: dark !important; color-scheme: dark !important;
--color-primary-10: 8, 31, 43;
--color-primary-20: 10, 37, 51;
--color-primary-30: 13, 49, 69;
--color-primary-40: 16, 58, 81;
--color-primary-50: 18, 68, 94;
--color-primary-60: 23, 86, 120;
--color-primary-70: 28, 104, 146;
--color-primary-80: 31, 116, 163;
--color-primary-90: 34, 129, 180;
--color-primary-100: 40, 146, 204;
--color-primary-200: 41, 154, 214;
--color-primary-300: 75, 170, 221;
--color-primary-400: 109, 186, 227;
--color-primary-500: 144, 202, 234;
--color-primary-600: 169, 214, 239;
--color-primary-700: 199, 229, 244;
--color-primary-800: 216, 237, 248;
--color-primary-900: 229, 243, 250;
--color-background-100: 25, 25, 25; /* primary bg */ --color-background-100: 25, 25, 25; /* primary bg */
--color-background-90: 32, 32, 32; /* secondary bg */ --color-background-90: 32, 32, 32; /* secondary bg */
--color-background-80: 44, 44, 44; /* tertiary bg */ --color-background-80: 44, 44, 44; /* tertiary bg */
@ -225,27 +222,6 @@
--color-border-300: 46, 46, 46; /* strong border- 1 */ --color-border-300: 46, 46, 46; /* strong border- 1 */
--color-border-400: 58, 58, 58; /* strong border- 2 */ --color-border-400: 58, 58, 58; /* strong border- 2 */
/* onboarding colors */
--gradient-onboarding-100: linear-gradient(106deg, #18191b 25.17%, #18191b 99.34%);
--gradient-onboarding-200: linear-gradient(129deg, rgba(47, 49, 53, 0.8) -22.23%, rgba(33, 34, 37, 0.8) 62.98%);
--gradient-onboarding-300: linear-gradient(167deg, rgba(47, 49, 53, 0.45) 19.22%, #212225 98.48%);
--color-onboarding-text-100: 237, 238, 240;
--color-onboarding-text-200: 176, 180, 187;
--color-onboarding-text-300: 118, 123, 132;
--color-onboarding-text-400: 105, 110, 119;
--color-onboarding-background-100: 54, 58, 64;
--color-onboarding-background-200: 40, 42, 45;
--color-onboarding-background-300: 40, 42, 45;
--color-onboarding-background-400: 67, 72, 79;
--color-onboarding-border-100: 54, 58, 64;
--color-onboarding-border-200: 54, 58, 64;
--color-onboarding-border-300: 34, 35, 38, 0.5;
--color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(39, 44, 56, 0.1);
/* toast theme */ /* toast theme */
--color-toast-success-text: 178, 221, 181; --color-toast-success-text: 178, 221, 181;
--color-toast-error-text: 206, 44, 49; --color-toast-error-text: 206, 44, 49;
@ -286,25 +262,6 @@
[data-theme="dark"], [data-theme="dark"],
[data-theme="light-contrast"], [data-theme="light-contrast"],
[data-theme="dark-contrast"] { [data-theme="dark-contrast"] {
--color-primary-10: 236, 241, 255;
--color-primary-20: 217, 228, 255;
--color-primary-30: 197, 214, 255;
--color-primary-40: 178, 200, 255;
--color-primary-50: 159, 187, 255;
--color-primary-60: 140, 173, 255;
--color-primary-70: 121, 159, 255;
--color-primary-80: 101, 145, 255;
--color-primary-90: 82, 132, 255;
--color-primary-100: 63, 118, 255;
--color-primary-200: 57, 106, 230;
--color-primary-300: 50, 94, 204;
--color-primary-400: 44, 83, 179;
--color-primary-500: 38, 71, 153;
--color-primary-600: 32, 59, 128;
--color-primary-700: 25, 47, 102;
--color-primary-800: 19, 35, 76;
--color-primary-900: 13, 24, 51;
--color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */ --color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */
--color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */ --color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */
--color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */ --color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */

View file

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

View file

@ -3,3 +3,10 @@ from django.apps import AppConfig
class ApiConfig(AppConfig): class ApiConfig(AppConfig):
name = "plane.api" name = "plane.api"
def ready(self):
# Import authentication extensions to register them with drf-spectacular
try:
import plane.utils.openapi.auth # noqa
except ImportError:
pass

View file

@ -1,8 +1,14 @@
from .user import UserLiteSerializer from .user import UserLiteSerializer
from .workspace import WorkspaceLiteSerializer from .workspace import WorkspaceLiteSerializer
from .project import ProjectSerializer, ProjectLiteSerializer from .project import (
ProjectSerializer,
ProjectLiteSerializer,
ProjectCreateSerializer,
ProjectUpdateSerializer,
)
from .issue import ( from .issue import (
IssueSerializer, IssueSerializer,
LabelCreateUpdateSerializer,
LabelSerializer, LabelSerializer,
IssueLinkSerializer, IssueLinkSerializer,
IssueCommentSerializer, IssueCommentSerializer,
@ -10,9 +16,40 @@ from .issue import (
IssueActivitySerializer, IssueActivitySerializer,
IssueExpandSerializer, IssueExpandSerializer,
IssueLiteSerializer, IssueLiteSerializer,
IssueAttachmentUploadSerializer,
IssueSearchSerializer,
IssueCommentCreateSerializer,
IssueLinkCreateSerializer,
IssueLinkUpdateSerializer,
) )
from .state import StateLiteSerializer, StateSerializer from .state import StateLiteSerializer, StateSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer from .cycle import (
from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer CycleSerializer,
from .intake import IntakeIssueSerializer CycleIssueSerializer,
CycleLiteSerializer,
CycleIssueRequestSerializer,
TransferCycleIssueRequestSerializer,
CycleCreateSerializer,
CycleUpdateSerializer,
)
from .module import (
ModuleSerializer,
ModuleIssueSerializer,
ModuleLiteSerializer,
ModuleIssueRequestSerializer,
ModuleCreateSerializer,
ModuleUpdateSerializer,
)
from .intake import (
IntakeIssueSerializer,
IntakeIssueCreateSerializer,
IntakeIssueUpdateSerializer,
)
from .estimate import EstimatePointSerializer from .estimate import EstimatePointSerializer
from .asset import (
UserAssetUploadSerializer,
AssetUpdateSerializer,
GenericAssetUploadSerializer,
GenericAssetUpdateSerializer,
FileAssetSerializer,
)

View file

@ -0,0 +1,123 @@
# Third party imports
from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.db.models import FileAsset
class UserAssetUploadSerializer(serializers.Serializer):
"""
Serializer for user asset upload requests.
This serializer validates the metadata required to generate a presigned URL
for uploading user profile assets (avatar or cover image) directly to S3 storage.
Supports JPEG, PNG, WebP, JPG, and GIF image formats with size validation.
"""
name = serializers.CharField(help_text="Original filename of the asset")
type = serializers.ChoiceField(
choices=[
("image/jpeg", "JPEG"),
("image/png", "PNG"),
("image/webp", "WebP"),
("image/jpg", "JPG"),
("image/gif", "GIF"),
],
default="image/jpeg",
help_text="MIME type of the file",
style={"placeholder": "image/jpeg"},
)
size = serializers.IntegerField(help_text="File size in bytes")
entity_type = serializers.ChoiceField(
choices=[
(FileAsset.EntityTypeContext.USER_AVATAR, "User Avatar"),
(FileAsset.EntityTypeContext.USER_COVER, "User Cover"),
],
help_text="Type of user asset",
)
class AssetUpdateSerializer(serializers.Serializer):
"""
Serializer for asset status updates after successful upload completion.
Handles post-upload asset metadata updates including attribute modifications
and upload confirmation for S3-based file storage workflows.
"""
attributes = serializers.JSONField(
required=False, help_text="Additional attributes to update for the asset"
)
class GenericAssetUploadSerializer(serializers.Serializer):
"""
Serializer for generic asset upload requests with project association.
Validates metadata for generating presigned URLs for workspace assets including
project association, external system tracking, and file validation for
document management and content storage workflows.
"""
name = serializers.CharField(help_text="Original filename of the asset")
type = serializers.CharField(required=False, help_text="MIME type of the file")
size = serializers.IntegerField(help_text="File size in bytes")
project_id = serializers.UUIDField(
required=False,
help_text="UUID of the project to associate with the asset",
style={"placeholder": "123e4567-e89b-12d3-a456-426614174000"},
)
external_id = serializers.CharField(
required=False,
help_text="External identifier for the asset (for integration tracking)",
)
external_source = serializers.CharField(
required=False, help_text="External source system (for integration tracking)"
)
class GenericAssetUpdateSerializer(serializers.Serializer):
"""
Serializer for generic asset upload confirmation and status management.
Handles post-upload status updates for workspace assets including
upload completion marking and metadata finalization.
"""
is_uploaded = serializers.BooleanField(
default=True, help_text="Whether the asset has been successfully uploaded"
)
class FileAssetSerializer(BaseSerializer):
"""
Comprehensive file asset serializer with complete metadata and URL generation.
Provides full file asset information including storage metadata, access URLs,
relationship data, and upload status for complete asset management workflows.
"""
asset_url = serializers.CharField(read_only=True)
class Meta:
model = FileAsset
fields = "__all__"
read_only_fields = [
"id",
"created_by",
"updated_by",
"created_at",
"updated_at",
"workspace",
"project",
"issue",
"comment",
"page",
"draft_issue",
"user",
"is_deleted",
"deleted_at",
"storage_metadata",
"asset_url",
]

View file

@ -3,6 +3,13 @@ from rest_framework import serializers
class BaseSerializer(serializers.ModelSerializer): class BaseSerializer(serializers.ModelSerializer):
"""
Base serializer providing common functionality for all model serializers.
Features field filtering, dynamic expansion of related fields, and standardized
primary key handling for consistent API responses across the application.
"""
id = serializers.PrimaryKeyRelatedField(read_only=True) id = serializers.PrimaryKeyRelatedField(read_only=True)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -84,6 +91,7 @@ class BaseSerializer(serializers.ModelSerializer):
"project_lead": UserLiteSerializer, "project_lead": UserLiteSerializer,
"state": StateLiteSerializer, "state": StateLiteSerializer,
"created_by": UserLiteSerializer, "created_by": UserLiteSerializer,
"updated_by": UserLiteSerializer,
"issue": IssueSerializer, "issue": IssueSerializer,
"actor": UserLiteSerializer, "actor": UserLiteSerializer,
"owned_by": UserLiteSerializer, "owned_by": UserLiteSerializer,

View file

@ -8,16 +8,13 @@ from plane.db.models import Cycle, CycleIssue
from plane.utils.timezone_converter import convert_to_utc from plane.utils.timezone_converter import convert_to_utc
class CycleSerializer(BaseSerializer): class CycleCreateSerializer(BaseSerializer):
total_issues = serializers.IntegerField(read_only=True) """
cancelled_issues = serializers.IntegerField(read_only=True) Serializer for creating cycles with timezone handling and date validation.
completed_issues = serializers.IntegerField(read_only=True)
started_issues = serializers.IntegerField(read_only=True) Manages cycle creation including project timezone conversion, date range validation,
unstarted_issues = serializers.IntegerField(read_only=True) and UTC normalization for time-bound iteration planning and sprint management.
backlog_issues = serializers.IntegerField(read_only=True) """
total_estimates = serializers.FloatField(read_only=True)
completed_estimates = serializers.FloatField(read_only=True)
started_estimates = serializers.FloatField(read_only=True)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -27,6 +24,29 @@ class CycleSerializer(BaseSerializer):
self.fields["start_date"].timezone = project_timezone self.fields["start_date"].timezone = project_timezone
self.fields["end_date"].timezone = project_timezone self.fields["end_date"].timezone = project_timezone
class Meta:
model = Cycle
fields = [
"name",
"description",
"start_date",
"end_date",
"owned_by",
"external_source",
"external_id",
"timezone",
]
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
"deleted_at",
]
def validate(self, data): def validate(self, data):
if ( if (
data.get("start_date", None) is not None data.get("start_date", None) is not None
@ -59,6 +79,40 @@ class CycleSerializer(BaseSerializer):
) )
return data return data
class CycleUpdateSerializer(CycleCreateSerializer):
"""
Serializer for updating cycles with enhanced ownership management.
Extends cycle creation with update-specific features including ownership
assignment and modification tracking for cycle lifecycle management.
"""
class Meta(CycleCreateSerializer.Meta):
model = Cycle
fields = CycleCreateSerializer.Meta.fields + [
"owned_by",
]
class CycleSerializer(BaseSerializer):
"""
Cycle serializer with comprehensive project metrics and time tracking.
Provides cycle details including work item counts by status, progress estimates,
and time-bound iteration data for project management and sprint planning.
"""
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True)
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
total_estimates = serializers.FloatField(read_only=True)
completed_estimates = serializers.FloatField(read_only=True)
started_estimates = serializers.FloatField(read_only=True)
class Meta: class Meta:
model = Cycle model = Cycle
fields = "__all__" fields = "__all__"
@ -76,6 +130,13 @@ class CycleSerializer(BaseSerializer):
class CycleIssueSerializer(BaseSerializer): class CycleIssueSerializer(BaseSerializer):
"""
Serializer for cycle-issue relationships with sub-issue counting.
Manages the association between cycles and work items, including
hierarchical issue tracking for nested work item structures.
"""
sub_issues_count = serializers.IntegerField(read_only=True) sub_issues_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
@ -85,6 +146,39 @@ class CycleIssueSerializer(BaseSerializer):
class CycleLiteSerializer(BaseSerializer): class CycleLiteSerializer(BaseSerializer):
"""
Lightweight cycle serializer for minimal data transfer.
Provides essential cycle information without computed metrics,
optimized for list views and reference lookups.
"""
class Meta: class Meta:
model = Cycle model = Cycle
fields = "__all__" fields = "__all__"
class CycleIssueRequestSerializer(serializers.Serializer):
"""
Serializer for bulk work item assignment to cycles.
Validates work item ID lists for batch operations including
cycle assignment and sprint planning workflows.
"""
issues = serializers.ListField(
child=serializers.UUIDField(), help_text="List of issue IDs to add to the cycle"
)
class TransferCycleIssueRequestSerializer(serializers.Serializer):
"""
Serializer for transferring work items between cycles.
Handles work item migration between cycles including validation
and relationship updates for sprint reallocation workflows.
"""
new_cycle_id = serializers.UUIDField(
help_text="ID of the target cycle to transfer issues to"
)

View file

@ -4,6 +4,13 @@ from .base import BaseSerializer
class EstimatePointSerializer(BaseSerializer): class EstimatePointSerializer(BaseSerializer):
"""
Serializer for project estimation points and story point values.
Handles numeric estimation data for work item sizing and sprint planning,
providing standardized point values for project velocity calculations.
"""
class Meta: class Meta:
model = EstimatePoint model = EstimatePoint
fields = ["id", "value"] fields = ["id", "value"]

View file

@ -1,11 +1,77 @@
# Module improts # Module improts
from .base import BaseSerializer from .base import BaseSerializer
from .issue import IssueExpandSerializer from .issue import IssueExpandSerializer
from plane.db.models import IntakeIssue from plane.db.models import IntakeIssue, Issue
from rest_framework import serializers from rest_framework import serializers
class IssueForIntakeSerializer(BaseSerializer):
"""
Serializer for work item data within intake submissions.
Handles essential work item fields for intake processing including
content validation and priority assignment for triage workflows.
"""
class Meta:
model = Issue
fields = [
"name",
"description",
"description_html",
"priority",
]
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IntakeIssueCreateSerializer(BaseSerializer):
"""
Serializer for creating intake work items with embedded issue data.
Manages intake work item creation including nested issue creation,
status assignment, and source tracking for issue queue management.
"""
issue = IssueForIntakeSerializer(help_text="Issue data for the intake issue")
class Meta:
model = IntakeIssue
fields = [
"issue",
"intake",
"status",
"snoozed_till",
"duplicate_to",
"source",
"source_email",
]
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IntakeIssueSerializer(BaseSerializer): class IntakeIssueSerializer(BaseSerializer):
"""
Comprehensive serializer for intake work items with expanded issue details.
Provides full intake work item data including embedded issue information,
status tracking, and triage metadata for issue queue management.
"""
issue_detail = IssueExpandSerializer(read_only=True, source="issue") issue_detail = IssueExpandSerializer(read_only=True, source="issue")
inbox = serializers.UUIDField(source="intake.id", read_only=True) inbox = serializers.UUIDField(source="intake.id", read_only=True)
@ -22,3 +88,53 @@ class IntakeIssueSerializer(BaseSerializer):
"created_at", "created_at",
"updated_at", "updated_at",
] ]
class IntakeIssueUpdateSerializer(BaseSerializer):
"""
Serializer for updating intake work items and their associated issues.
Handles intake work item modifications including status changes, triage decisions,
and embedded issue updates for issue queue processing workflows.
"""
issue = IssueForIntakeSerializer(
required=False, help_text="Issue data to update in the intake issue"
)
class Meta:
model = IntakeIssue
fields = [
"status",
"snoozed_till",
"duplicate_to",
"source",
"source_email",
"issue",
]
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IssueDataSerializer(serializers.Serializer):
"""
Serializer for nested work item data in intake request payloads.
Validates core work item fields within intake requests including
content formatting, priority levels, and metadata for issue creation.
"""
name = serializers.CharField(max_length=255, help_text="Issue name")
description_html = serializers.CharField(
required=False, allow_null=True, help_text="Issue description HTML"
)
priority = serializers.ChoiceField(
choices=Issue.PRIORITY_CHOICES, default="none", help_text="Issue priority"
)

View file

@ -24,7 +24,6 @@ from plane.db.models import (
) )
from plane.utils.content_validator import ( from plane.utils.content_validator import (
validate_html_content, validate_html_content,
validate_json_content,
validate_binary_data, validate_binary_data,
) )
@ -40,6 +39,13 @@ from django.core.validators import URLValidator
class IssueSerializer(BaseSerializer): class IssueSerializer(BaseSerializer):
"""
Comprehensive work item serializer with full relationship management.
Handles complete work item lifecycle including assignees, labels, validation,
and related model updates. Supports dynamic field expansion and HTML content processing.
"""
assignees = serializers.ListField( assignees = serializers.ListField(
child=serializers.PrimaryKeyRelatedField( child=serializers.PrimaryKeyRelatedField(
queryset=User.objects.values_list("id", flat=True) queryset=User.objects.values_list("id", flat=True)
@ -82,20 +88,24 @@ class IssueSerializer(BaseSerializer):
raise serializers.ValidationError("Invalid HTML passed") raise serializers.ValidationError("Invalid HTML passed")
# Validate description content for security # Validate description content for security
if data.get("description"):
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if data.get("description_html"): if data.get("description_html"):
is_valid, error_msg = validate_html_content(data["description_html"]) is_valid, error_msg, sanitized_html = validate_html_content(
data["description_html"]
)
if not is_valid: if not is_valid:
raise serializers.ValidationError({"description_html": error_msg}) 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 data.get("description_binary"): if data.get("description_binary"):
is_valid, error_msg = validate_binary_data(data["description_binary"]) is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid: if not is_valid:
raise serializers.ValidationError({"description_binary": error_msg}) raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
# Validate assignees are from project # Validate assignees are from project
if data.get("assignees", []): if data.get("assignees", []):
@ -336,13 +346,58 @@ class IssueSerializer(BaseSerializer):
class IssueLiteSerializer(BaseSerializer): class IssueLiteSerializer(BaseSerializer):
"""
Lightweight work item serializer for minimal data transfer.
Provides essential work item identifiers optimized for list views,
references, and performance-critical operations.
"""
class Meta: class Meta:
model = Issue model = Issue
fields = ["id", "sequence_id", "project_id"] fields = ["id", "sequence_id", "project_id"]
read_only_fields = fields read_only_fields = fields
class LabelCreateUpdateSerializer(BaseSerializer):
"""
Serializer for creating and updating work item labels.
Manages label metadata including colors, descriptions, hierarchy,
and sorting for work item categorization and filtering.
"""
class Meta:
model = Label
fields = [
"name",
"color",
"description",
"external_source",
"external_id",
"parent",
"sort_order",
]
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
"deleted_at",
]
class LabelSerializer(BaseSerializer): class LabelSerializer(BaseSerializer):
"""
Full serializer for work item labels with complete metadata.
Provides comprehensive label information including hierarchical relationships,
visual properties, and organizational data for work item tagging.
"""
class Meta: class Meta:
model = Label model = Label
fields = "__all__" fields = "__all__"
@ -358,10 +413,17 @@ class LabelSerializer(BaseSerializer):
] ]
class IssueLinkSerializer(BaseSerializer): class IssueLinkCreateSerializer(BaseSerializer):
"""
Serializer for creating work item external links with validation.
Handles URL validation, format checking, and duplicate prevention
for attaching external resources to work items.
"""
class Meta: class Meta:
model = IssueLink model = IssueLink
fields = "__all__" fields = ["url", "issue_id"]
read_only_fields = [ read_only_fields = [
"id", "id",
"workspace", "workspace",
@ -397,6 +459,22 @@ class IssueLinkSerializer(BaseSerializer):
) )
return IssueLink.objects.create(**validated_data) return IssueLink.objects.create(**validated_data)
class IssueLinkUpdateSerializer(IssueLinkCreateSerializer):
"""
Serializer for updating work item external links.
Extends link creation with update-specific validation to prevent
URL conflicts and maintain link integrity during modifications.
"""
class Meta(IssueLinkCreateSerializer.Meta):
model = IssueLink
fields = IssueLinkCreateSerializer.Meta.fields + [
"issue_id",
]
read_only_fields = IssueLinkCreateSerializer.Meta.read_only_fields
def update(self, instance, validated_data): def update(self, instance, validated_data):
if ( if (
IssueLink.objects.filter( IssueLink.objects.filter(
@ -412,7 +490,37 @@ class IssueLinkSerializer(BaseSerializer):
return super().update(instance, validated_data) return super().update(instance, validated_data)
class IssueLinkSerializer(BaseSerializer):
"""
Full serializer for work item external links.
Provides complete link information including metadata and timestamps
for managing external resource associations with work items.
"""
class Meta:
model = IssueLink
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IssueAttachmentSerializer(BaseSerializer): class IssueAttachmentSerializer(BaseSerializer):
"""
Serializer for work item file attachments.
Manages file asset associations with work items including metadata,
storage information, and access control for document management.
"""
class Meta: class Meta:
model = FileAsset model = FileAsset
fields = "__all__" fields = "__all__"
@ -426,7 +534,47 @@ class IssueAttachmentSerializer(BaseSerializer):
] ]
class IssueCommentCreateSerializer(BaseSerializer):
"""
Serializer for creating work item comments.
Handles comment creation with JSON and HTML content support,
access control, and external integration tracking.
"""
class Meta:
model = IssueComment
fields = [
"comment_json",
"comment_html",
"access",
"external_source",
"external_id",
]
read_only_fields = [
"id",
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
"deleted_at",
"actor",
"comment_stripped",
"edited_at",
]
class IssueCommentSerializer(BaseSerializer): class IssueCommentSerializer(BaseSerializer):
"""
Full serializer for work item comments with membership context.
Provides complete comment data including member status, content formatting,
and edit tracking for collaborative work item discussions.
"""
is_member = serializers.BooleanField(read_only=True) is_member = serializers.BooleanField(read_only=True)
class Meta: class Meta:
@ -456,12 +604,26 @@ class IssueCommentSerializer(BaseSerializer):
class IssueActivitySerializer(BaseSerializer): class IssueActivitySerializer(BaseSerializer):
"""
Serializer for work item activity and change history.
Tracks and represents work item modifications, state changes,
and user interactions for audit trails and activity feeds.
"""
class Meta: class Meta:
model = IssueActivity model = IssueActivity
exclude = ["created_by", "updated_by"] exclude = ["created_by", "updated_by"]
class CycleIssueSerializer(BaseSerializer): class CycleIssueSerializer(BaseSerializer):
"""
Serializer for work items within cycles.
Provides cycle context for work items including cycle metadata
and timing information for sprint and iteration management.
"""
cycle = CycleSerializer(read_only=True) cycle = CycleSerializer(read_only=True)
class Meta: class Meta:
@ -469,6 +631,13 @@ class CycleIssueSerializer(BaseSerializer):
class ModuleIssueSerializer(BaseSerializer): class ModuleIssueSerializer(BaseSerializer):
"""
Serializer for work items within modules.
Provides module context for work items including module metadata
and organizational information for feature-based work grouping.
"""
module = ModuleSerializer(read_only=True) module = ModuleSerializer(read_only=True)
class Meta: class Meta:
@ -476,12 +645,26 @@ class ModuleIssueSerializer(BaseSerializer):
class LabelLiteSerializer(BaseSerializer): class LabelLiteSerializer(BaseSerializer):
"""
Lightweight label serializer for minimal data transfer.
Provides essential label information with visual properties,
optimized for UI display and performance-critical operations.
"""
class Meta: class Meta:
model = Label model = Label
fields = ["id", "name", "color"] fields = ["id", "name", "color"]
class IssueExpandSerializer(BaseSerializer): class IssueExpandSerializer(BaseSerializer):
"""
Extended work item serializer with full relationship expansion.
Provides work items with expanded related data including cycles, modules,
labels, assignees, and states for comprehensive data representation.
"""
cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True) cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True)
module = ModuleLiteSerializer(source="issue_module.module", read_only=True) module = ModuleLiteSerializer(source="issue_module.module", read_only=True)
@ -489,7 +672,6 @@ class IssueExpandSerializer(BaseSerializer):
assignees = serializers.SerializerMethodField() assignees = serializers.SerializerMethodField()
state = StateLiteSerializer(read_only=True) state = StateLiteSerializer(read_only=True)
def get_labels(self, obj): def get_labels(self, obj):
expand = self.context.get("expand", []) expand = self.context.get("expand", [])
if "labels" in expand: if "labels" in expand:
@ -507,7 +689,6 @@ class IssueExpandSerializer(BaseSerializer):
).data ).data
return [ia.assignee_id for ia in obj.issue_assignee.all()] return [ia.assignee_id for ia in obj.issue_assignee.all()]
class Meta: class Meta:
model = Issue model = Issue
fields = "__all__" fields = "__all__"
@ -520,3 +701,41 @@ class IssueExpandSerializer(BaseSerializer):
"created_at", "created_at",
"updated_at", "updated_at",
] ]
class IssueAttachmentUploadSerializer(serializers.Serializer):
"""
Serializer for work item attachment upload request validation.
Handles file upload metadata validation including size, type, and external
integration tracking for secure work item document attachment workflows.
"""
name = serializers.CharField(help_text="Original filename of the asset")
type = serializers.CharField(required=False, help_text="MIME type of the file")
size = serializers.IntegerField(help_text="File size in bytes")
external_id = serializers.CharField(
required=False,
help_text="External identifier for the asset (for integration tracking)",
)
external_source = serializers.CharField(
required=False, help_text="External source system (for integration tracking)"
)
class IssueSearchSerializer(serializers.Serializer):
"""
Serializer for work item search result data formatting.
Provides standardized search result structure including work item identifiers,
project context, and workspace information for search API responses.
"""
id = serializers.CharField(required=True, help_text="Issue ID")
name = serializers.CharField(required=True, help_text="Issue name")
sequence_id = serializers.CharField(required=True, help_text="Issue sequence ID")
project__identifier = serializers.CharField(
required=True, help_text="Project identifier"
)
project_id = serializers.CharField(required=True, help_text="Project ID")
workspace__slug = serializers.CharField(required=True, help_text="Workspace slug")

View file

@ -13,24 +13,33 @@ from plane.db.models import (
) )
class ModuleSerializer(BaseSerializer): class ModuleCreateSerializer(BaseSerializer):
"""
Serializer for creating modules with member validation and date checking.
Handles module creation including member assignment validation, date range verification,
and duplicate name prevention for feature-based project organization setup.
"""
members = serializers.ListField( members = serializers.ListField(
child=serializers.PrimaryKeyRelatedField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
queryset=User.objects.values_list("id", flat=True)
),
write_only=True, write_only=True,
required=False, required=False,
) )
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True)
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Module model = Module
fields = "__all__" fields = [
"name",
"description",
"start_date",
"target_date",
"status",
"lead",
"members",
"external_source",
"external_id",
]
read_only_fields = [ read_only_fields = [
"id", "id",
"workspace", "workspace",
@ -42,11 +51,6 @@ class ModuleSerializer(BaseSerializer):
"deleted_at", "deleted_at",
] ]
def to_representation(self, instance):
data = super().to_representation(instance)
data["members"] = [str(member.id) for member in instance.members.all()]
return data
def validate(self, data): def validate(self, data):
if ( if (
data.get("start_date", None) is not None data.get("start_date", None) is not None
@ -96,6 +100,22 @@ class ModuleSerializer(BaseSerializer):
return module return module
class ModuleUpdateSerializer(ModuleCreateSerializer):
"""
Serializer for updating modules with enhanced validation and member management.
Extends module creation with update-specific validations including member reassignment,
name conflict checking, and relationship management for module modifications.
"""
class Meta(ModuleCreateSerializer.Meta):
model = Module
fields = ModuleCreateSerializer.Meta.fields + [
"members",
]
read_only_fields = ModuleCreateSerializer.Meta.read_only_fields
def update(self, instance, validated_data): def update(self, instance, validated_data):
members = validated_data.pop("members", None) members = validated_data.pop("members", None)
module_name = validated_data.get("name") module_name = validated_data.get("name")
@ -131,7 +151,54 @@ class ModuleSerializer(BaseSerializer):
return super().update(instance, validated_data) return super().update(instance, validated_data)
class ModuleSerializer(BaseSerializer):
"""
Comprehensive module serializer with work item metrics and member management.
Provides complete module data including work item counts by status, member relationships,
and progress tracking for feature-based project organization.
"""
members = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
)
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True)
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
class Meta:
model = Module
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
"deleted_at",
]
def to_representation(self, instance):
data = super().to_representation(instance)
data["members"] = [str(member.id) for member in instance.members.all()]
return data
class ModuleIssueSerializer(BaseSerializer): class ModuleIssueSerializer(BaseSerializer):
"""
Serializer for module-work item relationships with sub-item counting.
Manages the association between modules and work items, including
hierarchical issue tracking for nested work item structures.
"""
sub_issues_count = serializers.IntegerField(read_only=True) sub_issues_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
@ -149,6 +216,13 @@ class ModuleIssueSerializer(BaseSerializer):
class ModuleLinkSerializer(BaseSerializer): class ModuleLinkSerializer(BaseSerializer):
"""
Serializer for module external links with URL validation.
Handles external resource associations with modules including
URL validation and duplicate prevention for reference management.
"""
class Meta: class Meta:
model = ModuleLink model = ModuleLink
fields = "__all__" fields = "__all__"
@ -174,6 +248,27 @@ class ModuleLinkSerializer(BaseSerializer):
class ModuleLiteSerializer(BaseSerializer): class ModuleLiteSerializer(BaseSerializer):
"""
Lightweight module serializer for minimal data transfer.
Provides essential module information without computed metrics,
optimized for list views and reference lookups.
"""
class Meta: class Meta:
model = Module model = Module
fields = "__all__" fields = "__all__"
class ModuleIssueRequestSerializer(serializers.Serializer):
"""
Serializer for bulk work item assignment to modules.
Validates work item ID lists for batch operations including
module assignment and work item organization workflows.
"""
issues = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs to add to the module",
)

View file

@ -2,17 +2,153 @@
from rest_framework import serializers from rest_framework import serializers
# Module imports # Module imports
from plane.db.models import Project, ProjectIdentifier, WorkspaceMember from plane.db.models import (
from plane.utils.content_validator import ( Project,
validate_html_content, ProjectIdentifier,
validate_json_content, WorkspaceMember,
validate_binary_data, State,
Estimate,
) )
from plane.utils.content_validator import (
validate_html_content,
)
from .base import BaseSerializer from .base import BaseSerializer
class ProjectCreateSerializer(BaseSerializer):
"""
Serializer for creating projects with workspace validation.
Handles project creation including identifier validation, member verification,
and workspace association for new project initialization.
"""
class Meta:
model = Project
fields = [
"name",
"description",
"project_lead",
"default_assignee",
"identifier",
"icon_prop",
"emoji",
"cover_image",
"module_view",
"cycle_view",
"issue_views_view",
"page_view",
"intake_view",
"guest_view_all_features",
"archive_in",
"close_in",
"timezone",
"logo_props",
"external_source",
"external_id",
"is_issue_type_enabled",
]
read_only_fields = [
"id",
"workspace",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
def validate(self, data):
if data.get("project_lead", None) is not None:
# Check if the project lead is a member of the workspace
if not WorkspaceMember.objects.filter(
workspace_id=self.context["workspace_id"],
member_id=data.get("project_lead"),
).exists():
raise serializers.ValidationError(
"Project lead should be a user in the workspace"
)
if data.get("default_assignee", None) is not None:
# Check if the default assignee is a member of the workspace
if not WorkspaceMember.objects.filter(
workspace_id=self.context["workspace_id"],
member_id=data.get("default_assignee"),
).exists():
raise serializers.ValidationError(
"Default assignee should be a user in the workspace"
)
return data
def create(self, validated_data):
identifier = validated_data.get("identifier", "").strip().upper()
if identifier == "":
raise serializers.ValidationError(detail="Project Identifier is required")
if ProjectIdentifier.objects.filter(
name=identifier, workspace_id=self.context["workspace_id"]
).exists():
raise serializers.ValidationError(detail="Project Identifier is taken")
project = Project.objects.create(
**validated_data, workspace_id=self.context["workspace_id"]
)
return project
class ProjectUpdateSerializer(ProjectCreateSerializer):
"""
Serializer for updating projects with enhanced state and estimation management.
Extends project creation with update-specific validations including default state
assignment, estimation configuration, and project setting modifications.
"""
class Meta(ProjectCreateSerializer.Meta):
model = Project
fields = ProjectCreateSerializer.Meta.fields + [
"default_state",
"estimate",
]
read_only_fields = ProjectCreateSerializer.Meta.read_only_fields
def update(self, instance, validated_data):
"""Update a project"""
if (
validated_data.get("default_state", None) is not None
and not State.objects.filter(
project=instance, id=validated_data.get("default_state")
).exists()
):
# Check if the default state is a state in the project
raise serializers.ValidationError(
"Default state should be a state in the project"
)
if (
validated_data.get("estimate", None) is not None
and not Estimate.objects.filter(
project=instance, id=validated_data.get("estimate")
).exists()
):
# Check if the estimate is a estimate in the project
raise serializers.ValidationError(
"Estimate should be a estimate in the project"
)
return super().update(instance, validated_data)
class ProjectSerializer(BaseSerializer): class ProjectSerializer(BaseSerializer):
"""
Comprehensive project serializer with metrics and member context.
Provides complete project data including member counts, cycle/module totals,
deployment status, and user-specific context for project management.
"""
total_members = serializers.IntegerField(read_only=True) total_members = serializers.IntegerField(read_only=True)
total_cycles = serializers.IntegerField(read_only=True) total_cycles = serializers.IntegerField(read_only=True)
total_modules = serializers.IntegerField(read_only=True) total_modules = serializers.IntegerField(read_only=True)
@ -63,27 +199,18 @@ class ProjectSerializer(BaseSerializer):
) )
# Validate description content for security # Validate description content for security
if "description" in data and data["description"]:
# For Project, description might be text field, not JSON
if isinstance(data["description"], dict):
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if "description_text" in data and data["description_text"]:
is_valid, error_msg = validate_json_content(data["description_text"])
if not is_valid:
raise serializers.ValidationError({"description_text": error_msg})
if "description_html" in data and data["description_html"]: if "description_html" in data and data["description_html"]:
if isinstance(data["description_html"], dict): if isinstance(data["description_html"], dict):
is_valid, error_msg = validate_json_content(data["description_html"]) is_valid, error_msg, sanitized_html = validate_html_content(
else:
is_valid, error_msg = validate_html_content(
str(data["description_html"]) str(data["description_html"])
) )
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
if not is_valid: if not is_valid:
raise serializers.ValidationError({"description_html": error_msg}) raise serializers.ValidationError(
{"error": "html content is not valid"}
)
return data return data
@ -109,6 +236,13 @@ class ProjectSerializer(BaseSerializer):
class ProjectLiteSerializer(BaseSerializer): class ProjectLiteSerializer(BaseSerializer):
"""
Lightweight project serializer for minimal data transfer.
Provides essential project information including identifiers, visual properties,
and basic metadata optimized for list views and references.
"""
cover_image_url = serializers.CharField(read_only=True) cover_image_url = serializers.CharField(read_only=True)
class Meta: class Meta:

View file

@ -4,6 +4,13 @@ from plane.db.models import State
class StateSerializer(BaseSerializer): class StateSerializer(BaseSerializer):
"""
Serializer for work item states with default state management.
Handles state creation and updates including default state validation
and automatic default state switching for workflow management.
"""
def validate(self, data): def validate(self, data):
# If the default is being provided then make all other states default False # If the default is being provided then make all other states default False
if data.get("default", False): if data.get("default", False):
@ -24,10 +31,18 @@ class StateSerializer(BaseSerializer):
"workspace", "workspace",
"project", "project",
"deleted_at", "deleted_at",
"slug",
] ]
class StateLiteSerializer(BaseSerializer): class StateLiteSerializer(BaseSerializer):
"""
Lightweight state serializer for minimal data transfer.
Provides essential state information including visual properties
and grouping data optimized for UI display and filtering.
"""
class Meta: class Meta:
model = State model = State
fields = ["id", "name", "color", "group"] fields = ["id", "name", "color", "group"]

View file

@ -1,3 +1,5 @@
from rest_framework import serializers
# Module imports # Module imports
from plane.db.models import User from plane.db.models import User
@ -5,6 +7,18 @@ from .base import BaseSerializer
class UserLiteSerializer(BaseSerializer): class UserLiteSerializer(BaseSerializer):
"""
Lightweight user serializer for minimal data transfer.
Provides essential user information including names, avatar, and contact details
optimized for member lists, assignee displays, and user references.
"""
avatar_url = serializers.CharField(
help_text="Avatar URL",
read_only=True,
)
class Meta: class Meta:
model = User model = User
fields = [ fields = [

View file

@ -4,7 +4,12 @@ from .base import BaseSerializer
class WorkspaceLiteSerializer(BaseSerializer): class WorkspaceLiteSerializer(BaseSerializer):
"""Lite serializer with only required fields""" """
Lightweight workspace serializer for minimal data transfer.
Provides essential workspace identifiers including name, slug, and ID
optimized for navigation, references, and performance-critical operations.
"""
class Meta: class Meta:
model = Workspace model = Workspace

View file

@ -5,8 +5,11 @@ from .cycle import urlpatterns as cycle_patterns
from .module import urlpatterns as module_patterns from .module import urlpatterns as module_patterns
from .intake import urlpatterns as intake_patterns from .intake import urlpatterns as intake_patterns
from .member import urlpatterns as member_patterns from .member import urlpatterns as member_patterns
from .asset import urlpatterns as asset_patterns
from .user import urlpatterns as user_patterns
urlpatterns = [ urlpatterns = [
*asset_patterns,
*project_patterns, *project_patterns,
*state_patterns, *state_patterns,
*issue_patterns, *issue_patterns,
@ -14,4 +17,5 @@ urlpatterns = [
*module_patterns, *module_patterns,
*intake_patterns, *intake_patterns,
*member_patterns, *member_patterns,
*user_patterns,
] ]

View file

@ -0,0 +1,40 @@
from django.urls import path
from plane.api.views import (
UserAssetEndpoint,
UserServerAssetEndpoint,
GenericAssetEndpoint,
)
urlpatterns = [
path(
"assets/user-assets/",
UserAssetEndpoint.as_view(http_method_names=["post"]),
name="user-assets",
),
path(
"assets/user-assets/<uuid:asset_id>/",
UserAssetEndpoint.as_view(http_method_names=["patch", "delete"]),
name="user-assets-detail",
),
path(
"assets/user-assets/server/",
UserServerAssetEndpoint.as_view(http_method_names=["post"]),
name="user-server-assets",
),
path(
"assets/user-assets/<uuid:asset_id>/server/",
UserServerAssetEndpoint.as_view(http_method_names=["patch", "delete"]),
name="user-server-assets-detail",
),
path(
"workspaces/<str:slug>/assets/",
GenericAssetEndpoint.as_view(http_method_names=["post"]),
name="generic-asset",
),
path(
"workspaces/<str:slug>/assets/<uuid:asset_id>/",
GenericAssetEndpoint.as_view(http_method_names=["get", "patch"]),
name="generic-asset-detail",
),
]

View file

@ -1,8 +1,10 @@
from django.urls import path from django.urls import path
from plane.api.views.cycle import ( from plane.api.views.cycle import (
CycleAPIEndpoint, CycleListCreateAPIEndpoint,
CycleIssueAPIEndpoint, CycleDetailAPIEndpoint,
CycleIssueListCreateAPIEndpoint,
CycleIssueDetailAPIEndpoint,
TransferCycleIssueAPIEndpoint, TransferCycleIssueAPIEndpoint,
CycleArchiveUnarchiveAPIEndpoint, CycleArchiveUnarchiveAPIEndpoint,
) )
@ -10,37 +12,42 @@ from plane.api.views.cycle import (
urlpatterns = [ urlpatterns = [
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/", "workspaces/<str:slug>/projects/<uuid:project_id>/cycles/",
CycleAPIEndpoint.as_view(), CycleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="cycles", name="cycles",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:pk>/",
CycleAPIEndpoint.as_view(), CycleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="cycles", name="cycles",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/", "workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/",
CycleIssueAPIEndpoint.as_view(), CycleIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="cycle-issues", name="cycle-issues",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:issue_id>/", "workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:issue_id>/",
CycleIssueAPIEndpoint.as_view(), CycleIssueDetailAPIEndpoint.as_view(http_method_names=["get", "delete"]),
name="cycle-issues", name="cycle-issues",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/transfer-issues/", "workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/transfer-issues/",
TransferCycleIssueAPIEndpoint.as_view(), TransferCycleIssueAPIEndpoint.as_view(http_method_names=["post"]),
name="transfer-issues", name="transfer-issues",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/archive/", "workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/archive/",
CycleArchiveUnarchiveAPIEndpoint.as_view(), CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post"]),
name="cycle-archive-unarchive", name="cycle-archive-unarchive",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-cycles/", "workspaces/<str:slug>/projects/<uuid:project_id>/archived-cycles/",
CycleArchiveUnarchiveAPIEndpoint.as_view(), CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["get"]),
name="cycle-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-cycles/<uuid:pk>/unarchive/",
CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["delete"]),
name="cycle-archive-unarchive", name="cycle-archive-unarchive",
), ),
] ]

View file

@ -1,17 +1,22 @@
from django.urls import path from django.urls import path
from plane.api.views import IntakeIssueAPIEndpoint from plane.api.views import (
IntakeIssueListCreateAPIEndpoint,
IntakeIssueDetailAPIEndpoint,
)
urlpatterns = [ urlpatterns = [
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-issues/", "workspaces/<str:slug>/projects/<uuid:project_id>/intake-issues/",
IntakeIssueAPIEndpoint.as_view(), IntakeIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="intake-issue", name="intake-issue",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-issues/<uuid:issue_id>/", "workspaces/<str:slug>/projects/<uuid:project_id>/intake-issues/<uuid:issue_id>/",
IntakeIssueAPIEndpoint.as_view(), IntakeIssueDetailAPIEndpoint.as_view(
http_method_names=["get", "patch", "delete"]
),
name="intake-issue", name="intake-issue",
), ),
] ]

View file

@ -1,79 +1,97 @@
from django.urls import path from django.urls import path
from plane.api.views import ( from plane.api.views import (
IssueAPIEndpoint, IssueListCreateAPIEndpoint,
LabelAPIEndpoint, IssueDetailAPIEndpoint,
IssueLinkAPIEndpoint, LabelListCreateAPIEndpoint,
IssueCommentAPIEndpoint, LabelDetailAPIEndpoint,
IssueActivityAPIEndpoint, IssueLinkListCreateAPIEndpoint,
IssueLinkDetailAPIEndpoint,
IssueCommentListCreateAPIEndpoint,
IssueCommentDetailAPIEndpoint,
IssueActivityListAPIEndpoint,
IssueActivityDetailAPIEndpoint,
IssueAttachmentListCreateAPIEndpoint,
IssueAttachmentDetailAPIEndpoint,
WorkspaceIssueAPIEndpoint, WorkspaceIssueAPIEndpoint,
IssueAttachmentEndpoint, IssueSearchEndpoint,
) )
urlpatterns = [ urlpatterns = [
path( path(
"workspaces/<str:slug>/issues/<str:project__identifier>-<str:issue__identifier>/", "workspaces/<str:slug>/issues/search/",
WorkspaceIssueAPIEndpoint.as_view(), IssueSearchEndpoint.as_view(http_method_names=["get"]),
name="issue-search",
),
path(
"workspaces/<str:slug>/issues/<str:project_identifier>-<str:issue_identifier>/",
WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]),
name="issue-by-identifier", name="issue-by-identifier",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueAPIEndpoint.as_view(), IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="issue", name="issue",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
IssueAPIEndpoint.as_view(), IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="issue", name="issue",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/", "workspaces/<str:slug>/projects/<uuid:project_id>/labels/",
LabelAPIEndpoint.as_view(), LabelListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="label", name="label",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/labels/<uuid:pk>/",
LabelAPIEndpoint.as_view(), LabelDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="label", name="label",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/",
IssueLinkAPIEndpoint.as_view(), IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="link", name="link",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/<uuid:pk>/",
IssueLinkAPIEndpoint.as_view(), IssueLinkDetailAPIEndpoint.as_view(
http_method_names=["get", "patch", "delete"]
),
name="link", name="link",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
IssueCommentAPIEndpoint.as_view(), IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="comment", name="comment",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
IssueCommentAPIEndpoint.as_view(), IssueCommentDetailAPIEndpoint.as_view(
http_method_names=["get", "patch", "delete"]
),
name="comment", name="comment",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/",
IssueActivityAPIEndpoint.as_view(), IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]),
name="activity", name="activity",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/<uuid:pk>/",
IssueActivityAPIEndpoint.as_view(), IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]),
name="activity", name="activity",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
IssueAttachmentEndpoint.as_view(), IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="attachment", name="attachment",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
IssueAttachmentEndpoint.as_view(), IssueAttachmentDetailAPIEndpoint.as_view(
http_method_names=["get", "patch", "delete"]
),
name="issue-attachment", name="issue-attachment",
), ),
] ]

View file

@ -1,11 +1,16 @@
from django.urls import path from django.urls import path
from plane.api.views import ProjectMemberAPIEndpoint from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
urlpatterns = [ urlpatterns = [
path( path(
"workspaces/<str:slug>/projects/<str:project_id>/members/", "workspaces/<str:slug>/projects/<uuid:project_id>/members/",
ProjectMemberAPIEndpoint.as_view(), ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]),
name="users", name="project-members",
) ),
path(
"workspaces/<str:slug>/members/",
WorkspaceMemberAPIEndpoint.as_view(http_method_names=["get"]),
name="workspace-members",
),
] ]

View file

@ -1,40 +1,47 @@
from django.urls import path from django.urls import path
from plane.api.views import ( from plane.api.views import (
ModuleAPIEndpoint, ModuleListCreateAPIEndpoint,
ModuleIssueAPIEndpoint, ModuleDetailAPIEndpoint,
ModuleIssueListCreateAPIEndpoint,
ModuleIssueDetailAPIEndpoint,
ModuleArchiveUnarchiveAPIEndpoint, ModuleArchiveUnarchiveAPIEndpoint,
) )
urlpatterns = [ urlpatterns = [
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/", "workspaces/<str:slug>/projects/<uuid:project_id>/modules/",
ModuleAPIEndpoint.as_view(), ModuleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="modules", name="modules",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:pk>/",
ModuleAPIEndpoint.as_view(), ModuleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="modules", name="modules-detail",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/", "workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/",
ModuleIssueAPIEndpoint.as_view(), ModuleIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="module-issues", name="module-issues",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:issue_id>/", "workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:issue_id>/",
ModuleIssueAPIEndpoint.as_view(), ModuleIssueDetailAPIEndpoint.as_view(http_method_names=["delete"]),
name="module-issues", name="module-issues-detail",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:pk>/archive/", "workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:pk>/archive/",
ModuleArchiveUnarchiveAPIEndpoint.as_view(), ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post"]),
name="module-archive-unarchive", name="module-archive",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-modules/", "workspaces/<str:slug>/projects/<uuid:project_id>/archived-modules/",
ModuleArchiveUnarchiveAPIEndpoint.as_view(), ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["get"]),
name="module-archive-unarchive", name="module-archive-list",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-modules/<uuid:pk>/unarchive/",
ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["delete"]),
name="module-unarchive",
), ),
] ]

View file

@ -1,19 +1,27 @@
from django.urls import path from django.urls import path
from plane.api.views import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint from plane.api.views import (
ProjectListCreateAPIEndpoint,
ProjectDetailAPIEndpoint,
ProjectArchiveUnarchiveAPIEndpoint,
)
urlpatterns = [ urlpatterns = [
path( path(
"workspaces/<str:slug>/projects/", ProjectAPIEndpoint.as_view(), name="project" "workspaces/<str:slug>/projects/",
ProjectListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="project",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:pk>/",
ProjectAPIEndpoint.as_view(), ProjectDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="project", name="project",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archive/", "workspaces/<str:slug>/projects/<uuid:project_id>/archive/",
ProjectArchiveUnarchiveAPIEndpoint.as_view(), ProjectArchiveUnarchiveAPIEndpoint.as_view(
http_method_names=["post", "delete"]
),
name="project-archive-unarchive", name="project-archive-unarchive",
), ),
] ]

View file

@ -0,0 +1,20 @@
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
)
from django.urls import path
urlpatterns = [
path("schema/", SpectacularAPIView.as_view(), name="schema"),
path(
"schema/swagger-ui/",
SpectacularSwaggerView.as_view(url_name="schema"),
name="swagger-ui",
),
path(
"schema/redoc/",
SpectacularRedocView.as_view(url_name="schema"),
name="redoc",
),
]

View file

@ -1,16 +1,19 @@
from django.urls import path from django.urls import path
from plane.api.views import StateAPIEndpoint from plane.api.views import (
StateListCreateAPIEndpoint,
StateDetailAPIEndpoint,
)
urlpatterns = [ urlpatterns = [
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/", "workspaces/<str:slug>/projects/<uuid:project_id>/states/",
StateAPIEndpoint.as_view(), StateListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="states", name="states",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:state_id>/", "workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:state_id>/",
StateAPIEndpoint.as_view(), StateDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="states", name="states",
), ),
] ]

View file

@ -0,0 +1,11 @@
from django.urls import path
from plane.api.views import UserEndpoint
urlpatterns = [
path(
"users/me/",
UserEndpoint.as_view(http_method_names=["get"]),
name="users",
),
]

View file

@ -1,30 +1,55 @@
from .project import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint from .project import (
ProjectListCreateAPIEndpoint,
ProjectDetailAPIEndpoint,
ProjectArchiveUnarchiveAPIEndpoint,
)
from .state import StateAPIEndpoint from .state import (
StateListCreateAPIEndpoint,
StateDetailAPIEndpoint,
)
from .issue import ( from .issue import (
WorkspaceIssueAPIEndpoint, WorkspaceIssueAPIEndpoint,
IssueAPIEndpoint, IssueListCreateAPIEndpoint,
LabelAPIEndpoint, IssueDetailAPIEndpoint,
IssueLinkAPIEndpoint, LabelListCreateAPIEndpoint,
IssueCommentAPIEndpoint, LabelDetailAPIEndpoint,
IssueActivityAPIEndpoint, IssueLinkListCreateAPIEndpoint,
IssueAttachmentEndpoint, IssueLinkDetailAPIEndpoint,
IssueCommentListCreateAPIEndpoint,
IssueCommentDetailAPIEndpoint,
IssueActivityListAPIEndpoint,
IssueActivityDetailAPIEndpoint,
IssueAttachmentListCreateAPIEndpoint,
IssueAttachmentDetailAPIEndpoint,
IssueSearchEndpoint,
) )
from .cycle import ( from .cycle import (
CycleAPIEndpoint, CycleListCreateAPIEndpoint,
CycleIssueAPIEndpoint, CycleDetailAPIEndpoint,
CycleIssueListCreateAPIEndpoint,
CycleIssueDetailAPIEndpoint,
TransferCycleIssueAPIEndpoint, TransferCycleIssueAPIEndpoint,
CycleArchiveUnarchiveAPIEndpoint, CycleArchiveUnarchiveAPIEndpoint,
) )
from .module import ( from .module import (
ModuleAPIEndpoint, ModuleListCreateAPIEndpoint,
ModuleIssueAPIEndpoint, ModuleDetailAPIEndpoint,
ModuleIssueListCreateAPIEndpoint,
ModuleIssueDetailAPIEndpoint,
ModuleArchiveUnarchiveAPIEndpoint, ModuleArchiveUnarchiveAPIEndpoint,
) )
from .member import ProjectMemberAPIEndpoint from .member import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
from .intake import IntakeIssueAPIEndpoint from .intake import (
IntakeIssueListCreateAPIEndpoint,
IntakeIssueDetailAPIEndpoint,
)
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint
from .user import UserEndpoint

View file

@ -0,0 +1,631 @@
# Python Imports
import uuid
# Django Imports
from django.utils import timezone
from django.conf import settings
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from drf_spectacular.utils import OpenApiExample, OpenApiRequest, OpenApiTypes
# Module Imports
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.settings.storage import S3Storage
from plane.db.models import FileAsset, User, Workspace
from plane.api.views.base import BaseAPIView
from plane.api.serializers import (
UserAssetUploadSerializer,
AssetUpdateSerializer,
GenericAssetUploadSerializer,
GenericAssetUpdateSerializer,
)
from plane.utils.openapi import (
ASSET_ID_PARAMETER,
WORKSPACE_SLUG_PARAMETER,
PRESIGNED_URL_SUCCESS_RESPONSE,
GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE,
GENERIC_ASSET_VALIDATION_ERROR_RESPONSE,
ASSET_CONFLICT_RESPONSE,
ASSET_DOWNLOAD_SUCCESS_RESPONSE,
ASSET_DOWNLOAD_ERROR_RESPONSE,
ASSET_UPDATED_RESPONSE,
ASSET_DELETED_RESPONSE,
VALIDATION_ERROR_RESPONSE,
ASSET_NOT_FOUND_RESPONSE,
NOT_FOUND_RESPONSE,
UNAUTHORIZED_RESPONSE,
asset_docs,
)
from plane.utils.exception_logger import log_exception
class UserAssetEndpoint(BaseAPIView):
"""This endpoint is used to upload user profile images."""
def asset_delete(self, asset_id):
asset = FileAsset.objects.filter(id=asset_id).first()
if asset is None:
return
asset.is_deleted = True
asset.deleted_at = timezone.now()
asset.save(update_fields=["is_deleted", "deleted_at"])
return
def entity_asset_delete(self, entity_type, asset, request):
# User Avatar
if entity_type == FileAsset.EntityTypeContext.USER_AVATAR:
user = User.objects.get(id=asset.user_id)
user.avatar_asset_id = None
user.save()
return
# User Cover
if entity_type == FileAsset.EntityTypeContext.USER_COVER:
user = User.objects.get(id=asset.user_id)
user.cover_image_asset_id = None
user.save()
return
return
@asset_docs(
operation_id="create_user_asset_upload",
summary="Generate presigned URL for user asset upload",
description="Generate presigned URL for user asset upload",
request=OpenApiRequest(
request=UserAssetUploadSerializer,
examples=[
OpenApiExample(
"User Avatar Upload",
value={
"name": "profile.jpg",
"type": "image/jpeg",
"size": 1024000,
"entity_type": "USER_AVATAR",
},
description="Example request for uploading a user avatar",
),
OpenApiExample(
"User Cover Upload",
value={
"name": "cover.jpg",
"type": "image/jpeg",
"size": 1024000,
"entity_type": "USER_COVER",
},
description="Example request for uploading a user cover",
),
],
),
responses={
200: PRESIGNED_URL_SUCCESS_RESPONSE,
400: VALIDATION_ERROR_RESPONSE,
401: UNAUTHORIZED_RESPONSE,
},
)
def post(self, request):
"""Generate presigned URL for user asset upload.
Create a presigned URL for uploading user profile assets (avatar or cover image).
This endpoint generates the necessary credentials for direct S3 upload.
"""
# get the asset key
name = request.data.get("name")
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type", False)
# Check if the file size is within the limit
size_limit = min(size, settings.FILE_SIZE_LIMIT)
# Check if the entity type is allowed
if not entity_type or entity_type not in ["USER_AVATAR", "USER_COVER"]:
return Response(
{"error": "Invalid entity type.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the file type is allowed
allowed_types = [
"image/jpeg",
"image/png",
"image/webp",
"image/jpg",
"image/gif",
]
if type not in allowed_types:
return Response(
{
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
"status": False,
},
status=status.HTTP_400_BAD_REQUEST,
)
# asset key
asset_key = f"{uuid.uuid4().hex}-{name}"
# Create a File Asset
asset = FileAsset.objects.create(
attributes={"name": name, "type": type, "size": size_limit},
asset=asset_key,
size=size_limit,
user=request.user,
created_by=request.user,
entity_type=entity_type,
)
# 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
)
# Return the presigned URL
return Response(
{
"upload_data": presigned_url,
"asset_id": str(asset.id),
"asset_url": asset.asset_url,
},
status=status.HTTP_200_OK,
)
@asset_docs(
operation_id="update_user_asset",
summary="Mark user asset as uploaded",
description="Mark user asset as uploaded",
parameters=[ASSET_ID_PARAMETER],
request=OpenApiRequest(
request=AssetUpdateSerializer,
examples=[
OpenApiExample(
"Update Asset Attributes",
value={
"attributes": {
"name": "updated_profile.jpg",
"type": "image/jpeg",
"size": 1024000,
},
"entity_type": "USER_AVATAR",
},
description="Example request for updating asset attributes",
),
],
),
responses={
204: ASSET_UPDATED_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
)
def patch(self, request, asset_id):
"""Update user asset after upload completion.
Update the asset status and attributes after the file has been uploaded to S3.
This endpoint should be called after completing the S3 upload to mark the asset as uploaded.
"""
# get the asset id
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
# get the storage metadata
asset.is_uploaded = True
# get the storage metadata
if not asset.storage_metadata:
get_asset_object_metadata.delay(asset_id=str(asset_id))
# update the attributes
asset.attributes = request.data.get("attributes", asset.attributes)
# save the asset
asset.save(update_fields=["is_uploaded", "attributes"])
return Response(status=status.HTTP_204_NO_CONTENT)
@asset_docs(
operation_id="delete_user_asset",
summary="Delete user asset",
parameters=[ASSET_ID_PARAMETER],
responses={
204: ASSET_DELETED_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
)
def delete(self, request, asset_id):
"""Delete user asset.
Delete a user profile asset (avatar or cover image) and remove its reference from the user profile.
This performs a soft delete by marking the asset as deleted and updating the user's profile.
"""
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
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
)
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
class UserServerAssetEndpoint(BaseAPIView):
"""This endpoint is used to upload user profile images."""
def asset_delete(self, asset_id):
asset = FileAsset.objects.filter(id=asset_id).first()
if asset is None:
return
asset.is_deleted = True
asset.deleted_at = timezone.now()
asset.save(update_fields=["is_deleted", "deleted_at"])
return
def entity_asset_delete(self, entity_type, asset, request):
# User Avatar
if entity_type == FileAsset.EntityTypeContext.USER_AVATAR:
user = User.objects.get(id=asset.user_id)
user.avatar_asset_id = None
user.save()
return
# User Cover
if entity_type == FileAsset.EntityTypeContext.USER_COVER:
user = User.objects.get(id=asset.user_id)
user.cover_image_asset_id = None
user.save()
return
return
@asset_docs(
operation_id="create_user_server_asset_upload",
summary="Generate presigned URL for user server asset upload",
request=UserAssetUploadSerializer,
responses={
200: PRESIGNED_URL_SUCCESS_RESPONSE,
400: VALIDATION_ERROR_RESPONSE,
},
)
def post(self, request):
"""Generate presigned URL for user server asset upload.
Create a presigned URL for uploading user profile assets (avatar or cover image) using server credentials.
This endpoint generates the necessary credentials for direct S3 upload with server-side authentication.
"""
# get the asset key
name = request.data.get("name")
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type", False)
# Check if the file size is within the limit
size_limit = min(size, settings.FILE_SIZE_LIMIT)
# Check if the entity type is allowed
if not entity_type or entity_type not in ["USER_AVATAR", "USER_COVER"]:
return Response(
{"error": "Invalid entity type.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the file type is allowed
allowed_types = [
"image/jpeg",
"image/png",
"image/webp",
"image/jpg",
"image/gif",
]
if type not in allowed_types:
return Response(
{
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
"status": False,
},
status=status.HTTP_400_BAD_REQUEST,
)
# asset key
asset_key = f"{uuid.uuid4().hex}-{name}"
# Create a File Asset
asset = FileAsset.objects.create(
attributes={"name": name, "type": type, "size": size_limit},
asset=asset_key,
size=size_limit,
user=request.user,
created_by=request.user,
entity_type=entity_type,
)
# Get the presigned URL
storage = S3Storage(request=request, is_server=True)
# 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
)
# Return the presigned URL
return Response(
{
"upload_data": presigned_url,
"asset_id": str(asset.id),
"asset_url": asset.asset_url,
},
status=status.HTTP_200_OK,
)
@asset_docs(
operation_id="update_user_server_asset",
summary="Mark user server asset as uploaded",
parameters=[ASSET_ID_PARAMETER],
request=AssetUpdateSerializer,
responses={
204: ASSET_UPDATED_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
)
def patch(self, request, asset_id):
"""Update user server asset after upload completion.
Update the asset status and attributes after the file has been uploaded to S3 using server credentials.
This endpoint should be called after completing the S3 upload to mark the asset as uploaded.
"""
# get the asset id
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
# get the storage metadata
asset.is_uploaded = True
# get the storage metadata
if not asset.storage_metadata:
get_asset_object_metadata.delay(asset_id=str(asset_id))
# update the attributes
asset.attributes = request.data.get("attributes", asset.attributes)
# save the asset
asset.save(update_fields=["is_uploaded", "attributes"])
return Response(status=status.HTTP_204_NO_CONTENT)
@asset_docs(
operation_id="delete_user_server_asset",
summary="Delete user server asset",
parameters=[ASSET_ID_PARAMETER],
responses={
204: ASSET_DELETED_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
)
def delete(self, request, asset_id):
"""Delete user server asset.
Delete a user profile asset (avatar or cover image) using server credentials and remove its reference from the user profile.
This performs a soft delete by marking the asset as deleted and updating the user's profile.
"""
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
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
)
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
class GenericAssetEndpoint(BaseAPIView):
"""This endpoint is used to upload generic assets that can be later bound to entities."""
use_read_replica = True
@asset_docs(
operation_id="get_generic_asset",
summary="Get presigned URL for asset download",
description="Get presigned URL for asset download",
parameters=[WORKSPACE_SLUG_PARAMETER],
responses={
200: ASSET_DOWNLOAD_SUCCESS_RESPONSE,
400: ASSET_DOWNLOAD_ERROR_RESPONSE,
404: ASSET_NOT_FOUND_RESPONSE,
},
)
def get(self, request, slug, asset_id):
"""Get presigned URL for asset download.
Generate a presigned URL for downloading a generic asset.
The asset must be uploaded and associated with the specified workspace.
"""
try:
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
# Get the asset
asset = FileAsset.objects.get(
id=asset_id, workspace_id=workspace.id, is_deleted=False
)
# Check if the asset exists and is uploaded
if not asset.is_uploaded:
return Response(
{"error": "Asset not yet uploaded"},
status=status.HTTP_400_BAD_REQUEST,
)
# Generate presigned URL for GET
storage = S3Storage(request=request, is_server=True)
presigned_url = storage.generate_presigned_url(
object_name=asset.asset.name, filename=asset.attributes.get("name")
)
return Response(
{
"asset_id": str(asset.id),
"asset_url": presigned_url,
"asset_name": asset.attributes.get("name", ""),
"asset_type": asset.attributes.get("type", ""),
},
status=status.HTTP_200_OK,
)
except Workspace.DoesNotExist:
return Response(
{"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND
)
except FileAsset.DoesNotExist:
return Response(
{"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
log_exception(e)
return Response(
{"error": "Internal server error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@asset_docs(
operation_id="create_generic_asset_upload",
summary="Generate presigned URL for generic asset upload",
description="Generate presigned URL for generic asset upload",
parameters=[WORKSPACE_SLUG_PARAMETER],
request=OpenApiRequest(
request=GenericAssetUploadSerializer,
examples=[
OpenApiExample(
"GenericAssetUploadSerializer",
value={
"name": "image.jpg",
"type": "image/jpeg",
"size": 1024000,
"project_id": "123e4567-e89b-12d3-a456-426614174000",
"external_id": "1234567890",
"external_source": "github",
},
description="Example request for uploading a generic asset",
),
],
),
responses={
200: GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE,
400: GENERIC_ASSET_VALIDATION_ERROR_RESPONSE,
404: NOT_FOUND_RESPONSE,
409: ASSET_CONFLICT_RESPONSE,
},
)
def post(self, request, slug):
"""Generate presigned URL for generic asset upload.
Create a presigned URL for uploading generic assets that can be bound to entities like work items.
Supports various file types and includes external source tracking for integrations.
"""
name = request.data.get("name")
type = request.data.get("type")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
project_id = request.data.get("project_id")
external_id = request.data.get("external_id")
external_source = request.data.get("external_source")
# Check if the request is valid
if not name or not size:
return Response(
{"error": "Name and size are required fields.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the file size is within the limit
size_limit = min(size, settings.FILE_SIZE_LIMIT)
# Check if the file type is allowed
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
return Response(
{"error": "Invalid file type.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
# Check for existing asset with same external details if provided
if external_id and external_source:
existing_asset = FileAsset.objects.filter(
workspace__slug=slug,
external_source=external_source,
external_id=external_id,
is_deleted=False,
).first()
if existing_asset:
return Response(
{
"message": "Asset with same external id and source already exists",
"asset_id": str(existing_asset.id),
"asset_url": existing_asset.asset_url,
},
status=status.HTTP_409_CONFLICT,
)
# Create a File Asset
asset = FileAsset.objects.create(
attributes={"name": name, "type": type, "size": size_limit},
asset=asset_key,
size=size_limit,
workspace_id=workspace.id,
project_id=project_id,
created_by=request.user,
external_id=external_id,
external_source=external_source,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, # Using ISSUE_ATTACHMENT since we'll bind it to issues
)
# Get the presigned URL
storage = S3Storage(request=request, is_server=True)
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
return Response(
{
"upload_data": presigned_url,
"asset_id": str(asset.id),
"asset_url": asset.asset_url,
},
status=status.HTTP_200_OK,
)
@asset_docs(
operation_id="update_generic_asset",
summary="Update generic asset after upload completion",
description="Update generic asset after upload completion",
parameters=[WORKSPACE_SLUG_PARAMETER, ASSET_ID_PARAMETER],
request=OpenApiRequest(
request=GenericAssetUpdateSerializer,
examples=[
OpenApiExample(
"GenericAssetUpdateSerializer",
value={"is_uploaded": True},
description="Example request for updating a generic asset",
)
],
),
responses={
204: ASSET_UPDATED_RESPONSE,
404: ASSET_NOT_FOUND_RESPONSE,
},
)
def patch(self, request, slug, asset_id):
"""Update generic asset after upload completion.
Update the asset status after the file has been uploaded to S3.
This endpoint should be called after completing the S3 upload to mark the asset as uploaded
and trigger metadata extraction.
"""
try:
asset = FileAsset.objects.get(
id=asset_id, workspace__slug=slug, is_deleted=False
)
# Update is_uploaded status
asset.is_uploaded = request.data.get("is_uploaded", asset.is_uploaded)
# Update storage metadata if not present
if not asset.storage_metadata:
get_asset_object_metadata.delay(asset_id=str(asset_id))
asset.save(update_fields=["is_uploaded"])
return Response(status=status.HTTP_204_NO_CONTENT)
except FileAsset.DoesNotExist:
return Response(
{"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
)

View file

@ -13,13 +13,14 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
# Third party imports # Third party imports
from rest_framework.views import APIView from rest_framework.generics import GenericAPIView
# Module imports # Module imports
from plane.api.middleware.api_authentication import APIKeyAuthentication from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle
from plane.utils.exception_logger import log_exception from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator from plane.utils.paginator import BasePaginator
from plane.utils.core.mixins import ReadReplicaControlMixin
class TimezoneMixin: class TimezoneMixin:
@ -36,11 +37,15 @@ class TimezoneMixin:
timezone.deactivate() timezone.deactivate()
class BaseAPIView(TimezoneMixin, APIView, BasePaginator): class BaseAPIView(
TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePaginator
):
authentication_classes = [APIKeyAuthentication] authentication_classes = [APIKeyAuthentication]
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
use_read_replica = False
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
for backend in list(self.filter_backends): for backend in list(self.filter_backends):
queryset = backend().filter_queryset(self.request, queryset, self) queryset = backend().filter_queryset(self.request, queryset, self)

View file

@ -23,9 +23,18 @@ from django.db import models
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
# Module imports # Module imports
from plane.api.serializers import CycleIssueSerializer, CycleSerializer, IssueSerializer from plane.api.serializers import (
CycleIssueSerializer,
CycleSerializer,
CycleIssueRequestSerializer,
TransferCycleIssueRequestSerializer,
CycleCreateSerializer,
CycleUpdateSerializer,
IssueSerializer,
)
from plane.app.permissions import ProjectEntityPermission from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activities_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import ( from plane.db.models import (
@ -42,19 +51,42 @@ from plane.utils.analytics_plot import burndown_plot
from plane.utils.host import base_host from plane.utils.host import base_host
from .base import BaseAPIView from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity from plane.bgtasks.webhook_task import model_activity
from plane.utils.openapi.decorators import cycle_docs
from plane.utils.openapi import (
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
CYCLE_VIEW_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
create_paginated_response,
# Request Examples
CYCLE_CREATE_EXAMPLE,
CYCLE_UPDATE_EXAMPLE,
CYCLE_ISSUE_REQUEST_EXAMPLE,
TRANSFER_CYCLE_ISSUE_EXAMPLE,
# Response Examples
CYCLE_EXAMPLE,
CYCLE_ISSUE_EXAMPLE,
TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE,
TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE,
TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE,
DELETED_RESPONSE,
ARCHIVED_RESPONSE,
CYCLE_CANNOT_ARCHIVE_RESPONSE,
UNARCHIVED_RESPONSE,
REQUIRED_FIELDS_RESPONSE,
)
class CycleAPIEndpoint(BaseAPIView): class CycleListCreateAPIEndpoint(BaseAPIView):
""" """Cycle List and Create Endpoint"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to cycle.
"""
serializer_class = CycleSerializer serializer_class = CycleSerializer
model = Cycle model = Cycle
webhook_event = "cycle" webhook_event = "cycle"
permission_classes = [ProjectEntityPermission] permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self): def get_queryset(self):
return ( return (
@ -136,17 +168,34 @@ class CycleAPIEndpoint(BaseAPIView):
.distinct() .distinct()
) )
def get(self, request, slug, project_id, pk=None): @cycle_docs(
operation_id="list_cycles",
summary="List cycles",
description="Retrieve all cycles in a project. Supports filtering by cycle status like current, upcoming, completed, or draft.",
parameters=[
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
CYCLE_VIEW_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
responses={
200: create_paginated_response(
CycleSerializer,
"PaginatedCycleResponse",
"Paginated list of cycles",
"Paginated Cycles",
),
},
)
def get(self, request, slug, project_id):
"""List cycles
Retrieve all cycles in a project.
Supports filtering by cycle status like current, upcoming, completed, or draft.
"""
project = Project.objects.get(workspace__slug=slug, pk=project_id) project = Project.objects.get(workspace__slug=slug, pk=project_id)
if pk:
queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
data = CycleSerializer(
queryset,
fields=self.fields,
expand=self.expand,
context={"project": project},
).data
return Response(data, status=status.HTTP_200_OK)
queryset = self.get_queryset().filter(archived_at__isnull=True) queryset = self.get_queryset().filter(archived_at__isnull=True)
cycle_view = request.GET.get("cycle_view", "all") cycle_view = request.GET.get("cycle_view", "all")
@ -237,7 +286,28 @@ class CycleAPIEndpoint(BaseAPIView):
).data, ).data,
) )
@cycle_docs(
operation_id="create_cycle",
summary="Create cycle",
description="Create a new development cycle with specified name, description, and date range. Supports external ID tracking for integration purposes.",
request=OpenApiRequest(
request=CycleCreateSerializer,
examples=[CYCLE_CREATE_EXAMPLE],
),
responses={
201: OpenApiResponse(
description="Cycle created",
response=CycleSerializer,
examples=[CYCLE_EXAMPLE],
),
},
)
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
"""Create cycle
Create a new development cycle with specified name, description, and date range.
Supports external ID tracking for integration purposes.
"""
if ( if (
request.data.get("start_date", None) is None request.data.get("start_date", None) is None
and request.data.get("end_date", None) is None and request.data.get("end_date", None) is None
@ -245,7 +315,7 @@ class CycleAPIEndpoint(BaseAPIView):
request.data.get("start_date", None) is not None request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None and request.data.get("end_date", None) is not None
): ):
serializer = CycleSerializer(data=request.data) serializer = CycleCreateSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
if ( if (
request.data.get("external_id") request.data.get("external_id")
@ -274,13 +344,16 @@ class CycleAPIEndpoint(BaseAPIView):
# Send the model activity # Send the model activity
model_activity.delay( model_activity.delay(
model_name="cycle", model_name="cycle",
model_id=str(serializer.data["id"]), model_id=str(serializer.instance.id),
requested_data=request.data, requested_data=request.data,
current_instance=None, current_instance=None,
actor_id=request.user.id, actor_id=request.user.id,
slug=slug, slug=slug,
origin=base_host(request=request, is_app=True), origin=base_host(request=request, is_app=True),
) )
cycle = Cycle.objects.get(pk=serializer.instance.id)
serializer = CycleSerializer(cycle)
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else: else:
@ -291,7 +364,148 @@ class CycleAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
class CycleDetailAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `retrieve`, `update` and `destroy` actions related to cycle.
"""
serializer_class = CycleSerializer
model = Cycle
webhook_event = "cycle"
permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self):
return (
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
.distinct()
)
@cycle_docs(
operation_id="retrieve_cycle",
summary="Retrieve cycle",
description="Retrieve details of a specific cycle by its ID. Supports cycle status filtering.",
responses={
200: OpenApiResponse(
description="Cycles",
response=CycleSerializer,
examples=[CYCLE_EXAMPLE],
),
},
)
def get(self, request, slug, project_id, pk):
"""List or retrieve cycles
Retrieve all cycles in a project or get details of a specific cycle.
Supports filtering by cycle status like current, upcoming, completed, or draft.
"""
project = Project.objects.get(workspace__slug=slug, pk=project_id)
queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
data = CycleSerializer(
queryset,
fields=self.fields,
expand=self.expand,
context={"project": project},
).data
return Response(data, status=status.HTTP_200_OK)
@cycle_docs(
operation_id="update_cycle",
summary="Update cycle",
description="Modify an existing cycle's properties like name, description, or date range. Completed cycles can only have their sort order changed.",
request=OpenApiRequest(
request=CycleUpdateSerializer,
examples=[CYCLE_UPDATE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Cycle updated",
response=CycleSerializer,
examples=[CYCLE_EXAMPLE],
),
},
)
def patch(self, request, slug, project_id, pk): def patch(self, request, slug, project_id, pk):
"""Update cycle
Modify an existing cycle's properties like name, description, or date range.
Completed cycles can only have their sort order changed.
"""
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
current_instance = json.dumps( current_instance = json.dumps(
@ -320,7 +534,7 @@ class CycleAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
serializer = CycleSerializer(cycle, data=request.data, partial=True) serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True)
if serializer.is_valid(): if serializer.is_valid():
if ( if (
request.data.get("external_id") request.data.get("external_id")
@ -346,17 +560,32 @@ class CycleAPIEndpoint(BaseAPIView):
# Send the model activity # Send the model activity
model_activity.delay( model_activity.delay(
model_name="cycle", model_name="cycle",
model_id=str(serializer.data["id"]), model_id=str(serializer.instance.id),
requested_data=request.data, requested_data=request.data,
current_instance=current_instance, current_instance=current_instance,
actor_id=request.user.id, actor_id=request.user.id,
slug=slug, slug=slug,
origin=base_host(request=request, is_app=True), origin=base_host(request=request, is_app=True),
) )
cycle = Cycle.objects.get(pk=serializer.instance.id)
serializer = CycleSerializer(cycle)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@cycle_docs(
operation_id="delete_cycle",
summary="Delete cycle",
description="Permanently remove a cycle and all its associated issue relationships",
responses={
204: DELETED_RESPONSE,
},
)
def delete(self, request, slug, project_id, pk): def delete(self, request, slug, project_id, pk):
"""Delete cycle
Permanently remove a cycle and all its associated issue relationships.
Only admins or the cycle creator can perform this action.
"""
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
if cycle.owned_by_id != request.user.id and ( if cycle.owned_by_id != request.user.id and (
not ProjectMember.objects.filter( not ProjectMember.objects.filter(
@ -403,7 +632,10 @@ class CycleAPIEndpoint(BaseAPIView):
class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
"""Cycle Archive and Unarchive Endpoint"""
permission_classes = [ProjectEntityPermission] permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self): def get_queryset(self):
return ( return (
@ -509,7 +741,27 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
.distinct() .distinct()
) )
@cycle_docs(
operation_id="list_archived_cycles",
description="Retrieve all cycles that have been archived in the project.",
summary="List archived cycles",
parameters=[CURSOR_PARAMETER, PER_PAGE_PARAMETER],
request={},
responses={
200: create_paginated_response(
CycleSerializer,
"PaginatedArchivedCycleResponse",
"Paginated list of archived cycles",
"Paginated Archived Cycles",
),
},
)
def get(self, request, slug, project_id): def get(self, request, slug, project_id):
"""List archived cycles
Retrieve all cycles that have been archived in the project.
Returns paginated results with cycle statistics and completion data.
"""
return self.paginate( return self.paginate(
request=request, request=request,
queryset=(self.get_queryset()), queryset=(self.get_queryset()),
@ -518,7 +770,22 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
).data, ).data,
) )
@cycle_docs(
operation_id="archive_cycle",
summary="Archive cycle",
description="Move a completed cycle to archived status for historical tracking. Only cycles that have ended can be archived.",
request={},
responses={
204: ARCHIVED_RESPONSE,
400: CYCLE_CANNOT_ARCHIVE_RESPONSE,
},
)
def post(self, request, slug, project_id, cycle_id): def post(self, request, slug, project_id, cycle_id):
"""Archive cycle
Move a completed cycle to archived status for historical tracking.
Only cycles that have ended can be archived.
"""
cycle = Cycle.objects.get( cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug pk=cycle_id, project_id=project_id, workspace__slug=slug
) )
@ -537,7 +804,21 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
).delete() ).delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@cycle_docs(
operation_id="unarchive_cycle",
summary="Unarchive cycle",
description="Restore an archived cycle to active status, making it available for regular use.",
request={},
responses={
204: UNARCHIVED_RESPONSE,
},
)
def delete(self, request, slug, project_id, cycle_id): def delete(self, request, slug, project_id, cycle_id):
"""Unarchive cycle
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( cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug pk=cycle_id, project_id=project_id, workspace__slug=slug
) )
@ -546,18 +827,14 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
class CycleIssueAPIEndpoint(BaseAPIView): class CycleIssueListCreateAPIEndpoint(BaseAPIView):
""" """Cycle Issue List and Create Endpoint"""
This viewset automatically provides `list`, `create`,
and `destroy` actions related to cycle issues.
"""
serializer_class = CycleIssueSerializer serializer_class = CycleIssueSerializer
model = CycleIssue model = CycleIssue
webhook_event = "cycle_issue" webhook_event = "cycle_issue"
bulk = True
permission_classes = [ProjectEntityPermission] permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self): def get_queryset(self):
return ( return (
@ -583,20 +860,27 @@ class CycleIssueAPIEndpoint(BaseAPIView):
.distinct() .distinct()
) )
def get(self, request, slug, project_id, cycle_id, issue_id=None): @cycle_docs(
# Get operation_id="list_cycle_work_items",
if issue_id: summary="List cycle work items",
cycle_issue = CycleIssue.objects.get( description="Retrieve all work items assigned to a cycle.",
workspace__slug=slug, parameters=[CURSOR_PARAMETER, PER_PAGE_PARAMETER],
project_id=project_id, request={},
cycle_id=cycle_id, responses={
issue_id=issue_id, 200: create_paginated_response(
) CycleIssueSerializer,
serializer = CycleIssueSerializer( "PaginatedCycleIssueResponse",
cycle_issue, fields=self.fields, expand=self.expand "Paginated list of cycle work items",
) "Paginated Cycle Work Items",
return Response(serializer.data, status=status.HTTP_200_OK) ),
},
)
def get(self, request, slug, project_id, cycle_id):
"""List or retrieve cycle work items
Retrieve all work items assigned to a cycle or get details of a specific cycle work item.
Returns paginated results with work item details, assignees, and labels.
"""
# List # List
order_by = request.GET.get("order_by", "created_at") order_by = request.GET.get("order_by", "created_at")
issues = ( issues = (
@ -644,19 +928,41 @@ class CycleIssueAPIEndpoint(BaseAPIView):
).data, ).data,
) )
@cycle_docs(
operation_id="add_cycle_work_items",
summary="Add Work Items to Cycle",
description="Assign multiple work items to a cycle. Automatically handles bulk creation and updates with activity tracking.",
request=OpenApiRequest(
request=CycleIssueRequestSerializer,
examples=[CYCLE_ISSUE_REQUEST_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Cycle work items added",
response=CycleIssueSerializer,
examples=[CYCLE_ISSUE_EXAMPLE],
),
400: REQUIRED_FIELDS_RESPONSE,
},
)
def post(self, request, slug, project_id, cycle_id): def post(self, request, slug, project_id, cycle_id):
"""Add cycle issues
Assign multiple work items to a cycle or move them from another cycle.
Automatically handles bulk creation and updates with activity tracking.
"""
issues = request.data.get("issues", []) issues = request.data.get("issues", [])
if not issues: if not issues:
return Response( return Response(
{"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST {"error": "Work items are required"}, status=status.HTTP_400_BAD_REQUEST
) )
cycle = Cycle.objects.get( cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=cycle_id workspace__slug=slug, project_id=project_id, pk=cycle_id
) )
# Get all CycleIssues already created # Get all CycleWorkItems already created
cycle_issues = list( cycle_issues = list(
CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues) CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues)
) )
@ -730,7 +1036,88 @@ class CycleIssueAPIEndpoint(BaseAPIView):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
class CycleIssueDetailAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`,
and `destroy` actions related to cycle issues.
"""
serializer_class = CycleIssueSerializer
model = CycleIssue
webhook_event = "cycle_issue"
bulk = True
permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self):
return (
CycleIssue.objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(cycle_id=self.kwargs.get("cycle_id"))
.select_related("project")
.select_related("workspace")
.select_related("cycle")
.select_related("issue", "issue__state", "issue__project")
.prefetch_related("issue__assignees", "issue__labels")
.order_by(self.kwargs.get("order_by", "-created_at"))
.distinct()
)
@cycle_docs(
operation_id="retrieve_cycle_work_item",
summary="Retrieve cycle work item",
description="Retrieve details of a specific cycle work item.",
responses={
200: OpenApiResponse(
description="Cycle work items",
response=CycleIssueSerializer,
examples=[CYCLE_ISSUE_EXAMPLE],
),
},
)
def get(self, request, slug, project_id, cycle_id, issue_id):
"""Retrieve cycle work item
Retrieve details of a specific cycle work item.
Returns paginated results with work item details, assignees, and labels.
"""
cycle_issue = CycleIssue.objects.get(
workspace__slug=slug,
project_id=project_id,
cycle_id=cycle_id,
issue_id=issue_id,
)
serializer = CycleIssueSerializer(
cycle_issue, fields=self.fields, expand=self.expand
)
return Response(serializer.data, status=status.HTTP_200_OK)
@cycle_docs(
operation_id="delete_cycle_work_item",
summary="Delete cycle work item",
description="Remove a work item from a cycle while keeping the work item in the project.",
responses={
204: DELETED_RESPONSE,
},
)
def delete(self, request, slug, project_id, cycle_id, issue_id): def delete(self, request, slug, project_id, cycle_id, issue_id):
"""Remove cycle work item
Remove a work item from a cycle while keeping the work item in the project.
Records the removal activity for tracking purposes.
"""
cycle_issue = CycleIssue.objects.get( cycle_issue = CycleIssue.objects.get(
issue_id=issue_id, issue_id=issue_id,
workspace__slug=slug, workspace__slug=slug,
@ -764,7 +1151,54 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
permission_classes = [ProjectEntityPermission] permission_classes = [ProjectEntityPermission]
@cycle_docs(
operation_id="transfer_cycle_work_items",
summary="Transfer cycle work items",
description="Move incomplete work items from the current cycle to a new target cycle. Captures progress snapshot and transfers only unfinished work items.",
request=OpenApiRequest(
request=TransferCycleIssueRequestSerializer,
examples=[TRANSFER_CYCLE_ISSUE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Work items transferred successfully",
response={
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Success message",
"example": "Success",
},
},
},
examples=[TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE],
),
400: OpenApiResponse(
description="Bad request",
response={
"type": "object",
"properties": {
"error": {
"type": "string",
"description": "Error message",
"example": "New Cycle Id is required",
},
},
},
examples=[
TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE,
TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE,
],
),
},
)
def post(self, request, slug, project_id, cycle_id): def post(self, request, slug, project_id, cycle_id):
"""Transfer cycle issues
Move incomplete issues from the current cycle to a new target cycle.
Captures progress snapshot and transfers only unfinished work items.
"""
new_cycle_id = request.data.get("new_cycle_id", False) new_cycle_id = request.data.get("new_cycle_id", False)
if not new_cycle_id: if not new_cycle_id:

View file

@ -12,30 +12,49 @@ from django.contrib.postgres.fields import ArrayField
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
# Module imports # Module imports
from plane.api.serializers import IntakeIssueSerializer, IssueSerializer from plane.api.serializers import (
IntakeIssueSerializer,
IssueSerializer,
IntakeIssueCreateSerializer,
IntakeIssueUpdateSerializer,
)
from plane.app.permissions import ProjectLitePermission from plane.app.permissions import ProjectLitePermission
from plane.bgtasks.issue_activities_task import issue_activity 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
from plane.utils.host import base_host from plane.utils.host import base_host
from .base import BaseAPIView from .base import BaseAPIView
from plane.db.models.intake import SourceType from plane.db.models.intake import SourceType
from plane.utils.openapi import (
intake_docs,
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
ISSUE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
create_paginated_response,
# Request Examples
INTAKE_ISSUE_CREATE_EXAMPLE,
INTAKE_ISSUE_UPDATE_EXAMPLE,
# Response Examples
INTAKE_ISSUE_EXAMPLE,
INVALID_REQUEST_RESPONSE,
DELETED_RESPONSE,
)
class IntakeIssueAPIEndpoint(BaseAPIView): class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
""" """Intake Work Item List and Create Endpoint"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to intake issues.
"""
permission_classes = [ProjectLitePermission]
serializer_class = IntakeIssueSerializer serializer_class = IntakeIssueSerializer
model = IntakeIssue
filterset_fields = ["status"] model = Intake
permission_classes = [ProjectLitePermission]
use_read_replica = True
def get_queryset(self): def get_queryset(self):
intake = Intake.objects.filter( intake = Intake.objects.filter(
@ -61,13 +80,33 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at")) .order_by(self.kwargs.get("order_by", "-created_at"))
) )
def get(self, request, slug, project_id, issue_id=None): @intake_docs(
if issue_id: operation_id="get_intake_work_items_list",
intake_issue_queryset = self.get_queryset().get(issue_id=issue_id) summary="List intake work items",
intake_issue_data = IntakeIssueSerializer( description="Retrieve all work items in the project's intake queue. Returns paginated results when listing all intake work items.",
intake_issue_queryset, fields=self.fields, expand=self.expand parameters=[
).data WORKSPACE_SLUG_PARAMETER,
return Response(intake_issue_data, status=status.HTTP_200_OK) PROJECT_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
responses={
200: create_paginated_response(
IntakeIssueSerializer,
"PaginatedIntakeIssueResponse",
"Paginated list of intake work items",
"Paginated Intake Work Items",
),
},
)
def get(self, request, slug, project_id):
"""List intake work items
Retrieve all work items in the project's intake queue.
Returns paginated results when listing all intake work items.
"""
issue_queryset = self.get_queryset() issue_queryset = self.get_queryset()
return self.paginate( return self.paginate(
request=request, request=request,
@ -77,7 +116,33 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
).data, ).data,
) )
@intake_docs(
operation_id="create_intake_work_item",
summary="Create intake work item",
description="Submit a new work item to the project's intake queue for review and triage. Automatically creates the work item with default triage state and tracks activity.",
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
],
request=OpenApiRequest(
request=IntakeIssueCreateSerializer,
examples=[INTAKE_ISSUE_CREATE_EXAMPLE],
),
responses={
201: OpenApiResponse(
description="Intake work item created",
response=IntakeIssueSerializer,
examples=[INTAKE_ISSUE_EXAMPLE],
),
400: INVALID_REQUEST_RESPONSE,
},
)
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
"""Create intake work item
Submit a new work item to the project's intake queue for review and triage.
Automatically creates the work item with default triage state and tracks activity.
"""
if not request.data.get("issue", {}).get("name", False): if not request.data.get("issue", {}).get("name", False):
return Response( return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
@ -141,9 +206,100 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
) )
serializer = IntakeIssueSerializer(intake_issue) serializer = IntakeIssueSerializer(intake_issue)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_201_CREATED)
class IntakeIssueDetailAPIEndpoint(BaseAPIView):
"""Intake Issue API Endpoint"""
permission_classes = [ProjectLitePermission]
serializer_class = IntakeIssueSerializer
model = IntakeIssue
use_read_replica = True
filterset_fields = ["status"]
def get_queryset(self):
intake = Intake.objects.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
).first()
project = Project.objects.get(
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
)
if intake is None and not project.intake_view:
return IntakeIssue.objects.none()
return (
IntakeIssue.objects.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
intake_id=intake.id,
)
.select_related("issue", "workspace", "project")
.order_by(self.kwargs.get("order_by", "-created_at"))
)
@intake_docs(
operation_id="retrieve_intake_work_item",
summary="Retrieve intake work item",
description="Retrieve details of a specific intake work item.",
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
ISSUE_ID_PARAMETER,
],
responses={
200: OpenApiResponse(
description="Intake work item",
response=IntakeIssueSerializer,
examples=[INTAKE_ISSUE_EXAMPLE],
),
},
)
def get(self, request, slug, project_id, issue_id):
"""Retrieve intake work item
Retrieve details of a specific intake work item.
"""
intake_issue_queryset = self.get_queryset().get(issue_id=issue_id)
intake_issue_data = IntakeIssueSerializer(
intake_issue_queryset, fields=self.fields, expand=self.expand
).data
return Response(intake_issue_data, status=status.HTTP_200_OK)
@intake_docs(
operation_id="update_intake_work_item",
summary="Update intake work item",
description="Modify an existing intake work item's properties or status for triage processing. Supports status changes like accept, reject, or mark as duplicate.",
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
ISSUE_ID_PARAMETER,
],
request=OpenApiRequest(
request=IntakeIssueUpdateSerializer,
examples=[INTAKE_ISSUE_UPDATE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Intake work item updated",
response=IntakeIssueSerializer,
examples=[INTAKE_ISSUE_EXAMPLE],
),
400: INVALID_REQUEST_RESPONSE,
},
)
def patch(self, request, slug, project_id, issue_id): def patch(self, request, slug, project_id, issue_id):
"""Update intake work item
Modify an existing intake work item's properties or status for triage processing.
Supports status changes like accept, reject, or mark as duplicate.
"""
intake = Intake.objects.filter( intake = Intake.objects.filter(
workspace__slug=slug, project_id=project_id workspace__slug=slug, project_id=project_id
).first() ).first()
@ -180,7 +336,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
request.user.id request.user.id
): ):
return Response( return Response(
{"error": "You cannot edit intake issues"}, {"error": "You cannot edit intake work items"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
@ -251,7 +407,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
# Only project admins and members can edit intake issue attributes # Only project admins and members can edit intake issue attributes
if project_member.role > 15: if project_member.role > 15:
serializer = IntakeIssueSerializer( serializer = IntakeIssueUpdateSerializer(
intake_issue, data=request.data, partial=True intake_issue, data=request.data, partial=True
) )
current_instance = json.dumps( current_instance = json.dumps(
@ -301,7 +457,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
origin=base_host(request=request, is_app=True), origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id), intake=str(intake_issue.id),
) )
serializer = IntakeIssueSerializer(intake_issue)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else: else:
@ -309,7 +465,25 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK
) )
@intake_docs(
operation_id="delete_intake_work_item",
summary="Delete intake work item",
description="Permanently remove an intake work item from the triage queue. Also deletes the underlying work item if it hasn't been accepted yet.",
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
ISSUE_ID_PARAMETER,
],
responses={
204: DELETED_RESPONSE,
},
)
def delete(self, request, slug, project_id, issue_id): def delete(self, request, slug, project_id, issue_id):
"""Delete intake work item
Permanently remove an intake work item from the triage queue.
Also deletes the underlying work item if it hasn't been accepted yet.
"""
intake = Intake.objects.filter( intake = Intake.objects.filter(
workspace__slug=slug, project_id=project_id workspace__slug=slug, project_id=project_id
).first() ).first()
@ -349,7 +523,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
).exists() ).exists()
): ):
return Response( return Response(
{"error": "Only admin or creator can delete the issue"}, {"error": "Only admin or creator can delete the work item"},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
) )
issue.delete() issue.delete()

File diff suppressed because it is too large Load diff

View file

@ -1,29 +1,122 @@
# Python imports
import uuid
# Django imports
from django.contrib.auth.hashers import make_password
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
# Third Party imports # Third Party imports
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from drf_spectacular.utils import (
extend_schema,
OpenApiResponse,
)
# Module imports # Module imports
from .base import BaseAPIView from .base import BaseAPIView
from plane.api.serializers import UserLiteSerializer from plane.api.serializers import UserLiteSerializer
from plane.db.models import User, Workspace, Project, WorkspaceMember, ProjectMember from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember
from plane.app.permissions import ProjectMemberPermission, WorkSpaceAdminPermission
from plane.utils.openapi import (
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
UNAUTHORIZED_RESPONSE,
FORBIDDEN_RESPONSE,
WORKSPACE_NOT_FOUND_RESPONSE,
PROJECT_NOT_FOUND_RESPONSE,
WORKSPACE_MEMBER_EXAMPLE,
PROJECT_MEMBER_EXAMPLE,
)
from plane.app.permissions import ProjectMemberPermission
class WorkspaceMemberAPIEndpoint(BaseAPIView):
permission_classes = [WorkSpaceAdminPermission]
use_read_replica = True
@extend_schema(
operation_id="get_workspace_members",
summary="List workspace members",
description="Retrieve all users who are members of the specified workspace.",
tags=["Members"],
parameters=[WORKSPACE_SLUG_PARAMETER],
responses={
200: OpenApiResponse(
description="List of workspace members with their roles",
response={
"type": "array",
"items": {
"allOf": [
{"$ref": "#/components/schemas/UserLite"},
{
"type": "object",
"properties": {
"role": {
"type": "integer",
"description": "Member role in the workspace",
}
},
},
]
},
},
examples=[WORKSPACE_MEMBER_EXAMPLE],
),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: WORKSPACE_NOT_FOUND_RESPONSE,
},
)
# Get all the users that are present inside the workspace
def get(self, request, slug):
"""List workspace members
Retrieve all users who are members of the specified workspace.
Returns user profiles with their respective workspace roles and permissions.
"""
# Check if the workspace exists
if not Workspace.objects.filter(slug=slug).exists():
return Response(
{"error": "Provided workspace does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug
).select_related("member")
# Get all the users with their roles
users_with_roles = []
for workspace_member in workspace_members:
user_data = UserLiteSerializer(workspace_member.member).data
user_data["role"] = workspace_member.role
users_with_roles.append(user_data)
return Response(users_with_roles, status=status.HTTP_200_OK)
# API endpoint to get and insert users inside the workspace # API endpoint to get and insert users inside the workspace
class ProjectMemberAPIEndpoint(BaseAPIView): class ProjectMemberAPIEndpoint(BaseAPIView):
permission_classes = [ProjectMemberPermission] permission_classes = [ProjectMemberPermission]
use_read_replica = True
@extend_schema(
operation_id="get_project_members",
summary="List project members",
description="Retrieve all users who are members of the specified project.",
tags=["Members"],
parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
responses={
200: OpenApiResponse(
description="List of project members with their roles",
response=UserLiteSerializer,
examples=[PROJECT_MEMBER_EXAMPLE],
),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: PROJECT_NOT_FOUND_RESPONSE,
},
)
# Get all the users that are present inside the workspace # Get all the users that are present inside the workspace
def get(self, request, slug, project_id): def get(self, request, slug, project_id):
"""List project members
Retrieve all users who are members of the specified project.
Returns user profiles with their project-specific roles and access levels.
"""
# Check if the workspace exists # Check if the workspace exists
if not Workspace.objects.filter(slug=slug).exists(): if not Workspace.objects.filter(slug=slug).exists():
return Response( return Response(
@ -42,91 +135,3 @@ class ProjectMemberAPIEndpoint(BaseAPIView):
).data ).data
return Response(users, status=status.HTTP_200_OK) return Response(users, status=status.HTTP_200_OK)
# Insert a new user inside the workspace, and assign the user to the project
def post(self, request, slug, project_id):
# Check if user with email already exists, and send bad request if it's
# not present, check for workspace and valid project mandat
# ------------------- Validation -------------------
if (
request.data.get("email") is None
or request.data.get("display_name") is None
):
return Response(
{
"error": "Expected email, display_name, workspace_slug, project_id, one or more of the fields are missing."
},
status=status.HTTP_400_BAD_REQUEST,
)
email = request.data.get("email")
try:
validate_email(email)
except ValidationError:
return Response(
{"error": "Invalid email provided"}, status=status.HTTP_400_BAD_REQUEST
)
workspace = Workspace.objects.filter(slug=slug).first()
project = Project.objects.filter(pk=project_id).first()
if not all([workspace, project]):
return Response(
{"error": "Provided workspace or project does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if user exists
user = User.objects.filter(email=email).first()
workspace_member = None
project_member = None
if user:
# Check if user is part of the workspace
workspace_member = WorkspaceMember.objects.filter(
workspace=workspace, member=user
).first()
if workspace_member:
# Check if user is part of the project
project_member = ProjectMember.objects.filter(
project=project, member=user
).first()
if project_member:
return Response(
{"error": "User is already part of the workspace and project"},
status=status.HTTP_400_BAD_REQUEST,
)
# If user does not exist, create the user
if not user:
user = User.objects.create(
email=email,
display_name=request.data.get("display_name"),
first_name=request.data.get("first_name", ""),
last_name=request.data.get("last_name", ""),
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
is_active=False,
)
user.save()
# Create a workspace member for the user if not already a member
if not workspace_member:
workspace_member = WorkspaceMember.objects.create(
workspace=workspace, member=user, role=request.data.get("role", 5)
)
workspace_member.save()
# Create a project member for the user if not already a member
if not project_member:
project_member = ProjectMember.objects.create(
project=project, member=user, role=request.data.get("role", 5)
)
project_member.save()
# Serialize the user and return the response
user_data = UserLiteSerializer(user).data
return Response(user_data, status=status.HTTP_201_CREATED)

View file

@ -10,12 +10,16 @@ from django.core.serializers.json import DjangoJSONEncoder
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
# Module imports # Module imports
from plane.api.serializers import ( from plane.api.serializers import (
IssueSerializer, IssueSerializer,
ModuleIssueSerializer, ModuleIssueSerializer,
ModuleSerializer, ModuleSerializer,
ModuleIssueRequestSerializer,
ModuleCreateSerializer,
ModuleUpdateSerializer,
) )
from plane.app.permissions import ProjectEntityPermission from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activities_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
@ -34,19 +38,49 @@ from plane.db.models import (
from .base import BaseAPIView from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity from plane.bgtasks.webhook_task import model_activity
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.utils.openapi import (
module_docs,
module_issue_docs,
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
MODULE_ID_PARAMETER,
MODULE_PK_PARAMETER,
ISSUE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
create_paginated_response,
# Request Examples
MODULE_CREATE_EXAMPLE,
MODULE_UPDATE_EXAMPLE,
MODULE_ISSUE_REQUEST_EXAMPLE,
# Response Examples
MODULE_EXAMPLE,
MODULE_ISSUE_EXAMPLE,
INVALID_REQUEST_RESPONSE,
PROJECT_NOT_FOUND_RESPONSE,
EXTERNAL_ID_EXISTS_RESPONSE,
MODULE_NOT_FOUND_RESPONSE,
DELETED_RESPONSE,
ADMIN_ONLY_RESPONSE,
REQUIRED_FIELDS_RESPONSE,
MODULE_ISSUE_NOT_FOUND_RESPONSE,
ARCHIVED_RESPONSE,
CANNOT_ARCHIVE_RESPONSE,
UNARCHIVED_RESPONSE,
)
class ModuleAPIEndpoint(BaseAPIView): class ModuleListCreateAPIEndpoint(BaseAPIView):
""" """Module List and Create Endpoint"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module.
"""
model = Module
permission_classes = [ProjectEntityPermission]
serializer_class = ModuleSerializer serializer_class = ModuleSerializer
model = Module
webhook_event = "module" webhook_event = "module"
permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self): def get_queryset(self):
return ( return (
@ -136,9 +170,33 @@ class ModuleAPIEndpoint(BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at")) .order_by(self.kwargs.get("order_by", "-created_at"))
) )
@module_docs(
operation_id="create_module",
summary="Create module",
description="Create a new project module with specified name, description, and timeline.",
request=OpenApiRequest(
request=ModuleCreateSerializer,
examples=[MODULE_CREATE_EXAMPLE],
),
responses={
201: OpenApiResponse(
description="Module created",
response=ModuleSerializer,
examples=[MODULE_EXAMPLE],
),
400: INVALID_REQUEST_RESPONSE,
404: PROJECT_NOT_FOUND_RESPONSE,
409: EXTERNAL_ID_EXISTS_RESPONSE,
},
)
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
"""Create module
Create a new project module with specified name, description, and timeline.
Automatically assigns the creator as module lead and tracks activity.
"""
project = Project.objects.get(pk=project_id, workspace__slug=slug) project = Project.objects.get(pk=project_id, workspace__slug=slug)
serializer = ModuleSerializer( serializer = ModuleCreateSerializer(
data=request.data, data=request.data,
context={"project_id": project_id, "workspace_id": project.workspace_id}, context={"project_id": project_id, "workspace_id": project.workspace_id},
) )
@ -170,19 +228,185 @@ class ModuleAPIEndpoint(BaseAPIView):
# Send the model activity # Send the model activity
model_activity.delay( model_activity.delay(
model_name="module", model_name="module",
model_id=str(serializer.data["id"]), model_id=str(serializer.instance.id),
requested_data=request.data, requested_data=request.data,
current_instance=None, current_instance=None,
actor_id=request.user.id, actor_id=request.user.id,
slug=slug, slug=slug,
origin=base_host(request=request, is_app=True), origin=base_host(request=request, is_app=True),
) )
module = Module.objects.get(pk=serializer.data["id"]) module = Module.objects.get(pk=serializer.instance.id)
serializer = ModuleSerializer(module) serializer = ModuleSerializer(module)
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@module_docs(
operation_id="list_modules",
summary="List modules",
description="Retrieve all modules in a project.",
parameters=[
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
responses={
200: create_paginated_response(
ModuleSerializer,
"PaginatedModuleResponse",
"Paginated list of modules",
"Paginated Modules",
),
404: OpenApiResponse(description="Module not found"),
},
)
def get(self, request, slug, project_id):
"""List or retrieve modules
Retrieve all modules in a project or get details of a specific module.
Returns paginated results with module statistics and member information.
"""
return self.paginate(
request=request,
queryset=(self.get_queryset().filter(archived_at__isnull=True)),
on_results=lambda modules: ModuleSerializer(
modules, many=True, fields=self.fields, expand=self.expand
).data,
)
class ModuleDetailAPIEndpoint(BaseAPIView):
"""Module Detail Endpoint"""
model = Module
permission_classes = [ProjectEntityPermission]
serializer_class = ModuleSerializer
webhook_event = "module"
use_read_replica = True
def get_queryset(self):
return (
Module.objects.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
.select_related("workspace")
.select_related("lead")
.prefetch_related("members")
.prefetch_related(
Prefetch(
"link_module",
queryset=ModuleLink.objects.select_related("module", "created_by"),
)
)
.annotate(
total_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.annotate(
completed_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="completed",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.annotate(
cancelled_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="cancelled",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.annotate(
started_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="started",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.annotate(
unstarted_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="unstarted",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.annotate(
backlog_issues=Count(
"issue_module__issue__state__group",
filter=Q(
issue_module__issue__state__group="backlog",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
issue_module__deleted_at__isnull=True,
),
distinct=True,
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
)
@module_docs(
operation_id="update_module",
summary="Update module",
description="Modify an existing module's properties like name, description, status, or timeline.",
parameters=[
MODULE_PK_PARAMETER,
],
request=OpenApiRequest(
request=ModuleUpdateSerializer,
examples=[MODULE_UPDATE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Module updated successfully",
response=ModuleSerializer,
examples=[MODULE_EXAMPLE],
),
400: OpenApiResponse(
description="Invalid request data",
response=ModuleSerializer,
examples=[MODULE_UPDATE_EXAMPLE],
),
404: OpenApiResponse(description="Module not found"),
409: OpenApiResponse(
description="Module with same external ID already exists"
),
},
)
def patch(self, request, slug, project_id, pk): def patch(self, request, slug, project_id, pk):
"""Update module
Modify an existing module's properties like name, description, status, or timeline.
Tracks all changes in model activity logs for audit purposes.
"""
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
current_instance = json.dumps( current_instance = json.dumps(
@ -222,7 +446,7 @@ class ModuleAPIEndpoint(BaseAPIView):
# Send the model activity # Send the model activity
model_activity.delay( model_activity.delay(
model_name="module", model_name="module",
model_id=str(serializer.data["id"]), model_id=str(serializer.instance.id),
requested_data=request.data, requested_data=request.data,
current_instance=current_instance, current_instance=current_instance,
actor_id=request.user.id, actor_id=request.user.id,
@ -233,22 +457,50 @@ class ModuleAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get(self, request, slug, project_id, pk=None): @module_docs(
if pk: operation_id="retrieve_module",
queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk) summary="Retrieve module",
data = ModuleSerializer( description="Retrieve details of a specific module.",
queryset, fields=self.fields, expand=self.expand parameters=[
).data MODULE_PK_PARAMETER,
return Response(data, status=status.HTTP_200_OK) ],
return self.paginate( responses={
request=request, 200: OpenApiResponse(
queryset=(self.get_queryset().filter(archived_at__isnull=True)), description="Module",
on_results=lambda modules: ModuleSerializer( response=ModuleSerializer,
modules, many=True, fields=self.fields, expand=self.expand examples=[MODULE_EXAMPLE],
).data, ),
) 404: OpenApiResponse(description="Module not found"),
},
)
def get(self, request, slug, project_id, pk):
"""Retrieve module
Retrieve details of a specific module.
"""
queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
data = ModuleSerializer(queryset, fields=self.fields, expand=self.expand).data
return Response(data, status=status.HTTP_200_OK)
@module_docs(
operation_id="delete_module",
summary="Delete module",
description="Permanently remove a module and all its associated issue relationships.",
parameters=[
MODULE_PK_PARAMETER,
],
responses={
204: DELETED_RESPONSE,
403: ADMIN_ONLY_RESPONSE,
404: MODULE_NOT_FOUND_RESPONSE,
},
)
def delete(self, request, slug, project_id, pk): def delete(self, request, slug, project_id, pk):
"""Delete module
Permanently remove a module and all its associated issue relationships.
Only admins or the module creator can perform this action.
"""
module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
if module.created_by_id != request.user.id and ( if module.created_by_id != request.user.id and (
not ProjectMember.objects.filter( not ProjectMember.objects.filter(
@ -293,19 +545,14 @@ class ModuleAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleIssueAPIEndpoint(BaseAPIView): class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
""" """Module Work Item List and Create Endpoint"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module issues.
"""
serializer_class = ModuleIssueSerializer serializer_class = ModuleIssueSerializer
model = ModuleIssue model = ModuleIssue
webhook_event = "module_issue" webhook_event = "module_issue"
bulk = True
permission_classes = [ProjectEntityPermission] permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self): def get_queryset(self):
return ( return (
@ -333,7 +580,35 @@ class ModuleIssueAPIEndpoint(BaseAPIView):
.distinct() .distinct()
) )
@module_issue_docs(
operation_id="list_module_work_items",
summary="List module work items",
description="Retrieve all work items assigned to a module with detailed information.",
parameters=[
MODULE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
request={},
responses={
200: create_paginated_response(
IssueSerializer,
"PaginatedModuleIssueResponse",
"Paginated list of module work items",
"Paginated Module Work Items",
),
404: OpenApiResponse(description="Module not found"),
},
)
def get(self, request, slug, project_id, module_id): def get(self, request, slug, project_id, module_id):
"""List module work items
Retrieve all work items assigned to a module with detailed information.
Returns paginated results including assignees, labels, and attachments.
"""
order_by = request.GET.get("order_by", "created_at") order_by = request.GET.get("order_by", "created_at")
issues = ( issues = (
Issue.issue_objects.filter( Issue.issue_objects.filter(
@ -379,7 +654,33 @@ class ModuleIssueAPIEndpoint(BaseAPIView):
).data, ).data,
) )
@module_issue_docs(
operation_id="add_module_work_items",
summary="Add Work Items to Module",
description="Assign multiple work items to a module or move them from another module. Automatically handles bulk creation and updates with activity tracking.",
parameters=[
MODULE_ID_PARAMETER,
],
request=OpenApiRequest(
request=ModuleIssueRequestSerializer,
examples=[MODULE_ISSUE_REQUEST_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Module issues added",
response=ModuleIssueSerializer,
examples=[MODULE_ISSUE_EXAMPLE],
),
400: REQUIRED_FIELDS_RESPONSE,
404: MODULE_NOT_FOUND_RESPONSE,
},
)
def post(self, request, slug, project_id, module_id): def post(self, request, slug, project_id, module_id):
"""Add module work items
Assign multiple work items to a module or move them from another module.
Automatically handles bulk creation and updates with activity tracking.
"""
issues = request.data.get("issues", []) issues = request.data.get("issues", [])
if not len(issues): if not len(issues):
return Response( return Response(
@ -459,7 +760,143 @@ class ModuleIssueAPIEndpoint(BaseAPIView):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
class ModuleIssueDetailAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module work items.
"""
serializer_class = ModuleIssueSerializer
model = ModuleIssue
webhook_event = "module_issue"
bulk = True
use_read_replica = True
permission_classes = [ProjectEntityPermission]
def get_queryset(self):
return (
ModuleIssue.objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(module_id=self.kwargs.get("module_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(project__archived_at__isnull=True)
.select_related("project")
.select_related("workspace")
.select_related("module")
.select_related("issue", "issue__state", "issue__project")
.prefetch_related("issue__assignees", "issue__labels")
.prefetch_related("module__members")
.order_by(self.kwargs.get("order_by", "-created_at"))
.distinct()
)
@module_issue_docs(
operation_id="retrieve_module_work_item",
summary="Retrieve module work item",
description="Retrieve details of a specific module work item.",
parameters=[
MODULE_ID_PARAMETER,
ISSUE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
responses={
200: create_paginated_response(
IssueSerializer,
"PaginatedModuleIssueDetailResponse",
"Paginated list of module work item details",
"Module Work Item Details",
),
404: OpenApiResponse(description="Module not found"),
},
)
def get(self, request, slug, project_id, module_id, issue_id):
"""List module work items
Retrieve all work items assigned to a module with detailed information.
Returns paginated results including assignees, labels, and attachments.
"""
order_by = request.GET.get("order_by", "created_at")
issues = (
Issue.issue_objects.filter(
issue_module__module_id=module_id,
issue_module__deleted_at__isnull=True,
pk=issue_id,
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(bridge_id=F("issue_module__id"))
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.order_by(order_by)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
return self.paginate(
request=request,
queryset=(issues),
on_results=lambda issues: IssueSerializer(
issues, many=True, fields=self.fields, expand=self.expand
).data,
)
@module_issue_docs(
operation_id="delete_module_work_item",
summary="Delete module work item",
description="Remove a work item from a module while keeping the work item in the project.",
parameters=[
MODULE_ID_PARAMETER,
ISSUE_ID_PARAMETER,
],
responses={
204: DELETED_RESPONSE,
404: MODULE_ISSUE_NOT_FOUND_RESPONSE,
},
)
def delete(self, request, slug, project_id, module_id, issue_id): def delete(self, request, slug, project_id, module_id, issue_id):
"""Remove module work item
Remove a work item from a module while keeping the work item in the project.
Records the removal activity for tracking purposes.
"""
module_issue = ModuleIssue.objects.get( module_issue = ModuleIssue.objects.get(
workspace__slug=slug, workspace__slug=slug,
project_id=project_id, project_id=project_id,
@ -483,6 +920,7 @@ class ModuleIssueAPIEndpoint(BaseAPIView):
class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
permission_classes = [ProjectEntityPermission] permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self): def get_queryset(self):
return ( return (
@ -573,7 +1011,34 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at")) .order_by(self.kwargs.get("order_by", "-created_at"))
) )
def get(self, request, slug, project_id, pk): @module_docs(
operation_id="list_archived_modules",
summary="List archived modules",
description="Retrieve all modules that have been archived in the project.",
parameters=[
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
request={},
responses={
200: create_paginated_response(
ModuleSerializer,
"PaginatedArchivedModuleResponse",
"Paginated list of archived modules",
"Paginated Archived Modules",
),
404: OpenApiResponse(description="Project not found"),
},
)
def get(self, request, slug, project_id):
"""List archived modules
Retrieve all modules that have been archived in the project.
Returns paginated results with module statistics.
"""
return self.paginate( return self.paginate(
request=request, request=request,
queryset=(self.get_queryset()), queryset=(self.get_queryset()),
@ -582,7 +1047,26 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
).data, ).data,
) )
@module_docs(
operation_id="archive_module",
summary="Archive module",
description="Move a module to archived status for historical tracking.",
parameters=[
MODULE_PK_PARAMETER,
],
request={},
responses={
204: ARCHIVED_RESPONSE,
400: CANNOT_ARCHIVE_RESPONSE,
404: MODULE_NOT_FOUND_RESPONSE,
},
)
def post(self, request, slug, project_id, pk): def post(self, request, slug, project_id, pk):
"""Archive module
Move a completed module to archived status for historical tracking.
Only modules with completed status can be archived.
"""
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
if module.status not in ["completed", "cancelled"]: if module.status not in ["completed", "cancelled"]:
return Response( return Response(
@ -599,7 +1083,24 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
).delete() ).delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@module_docs(
operation_id="unarchive_module",
summary="Unarchive module",
description="Restore an archived module to active status, making it available for regular use.",
parameters=[
MODULE_PK_PARAMETER,
],
responses={
204: UNARCHIVED_RESPONSE,
404: MODULE_NOT_FOUND_RESPONSE,
},
)
def delete(self, request, slug, project_id, pk): def delete(self, request, slug, project_id, pk):
"""Unarchive module
Restore an archived module to active status, making it available for regular use.
The module will reappear in active module lists and become fully functional.
"""
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
module.archived_at = None module.archived_at = None
module.save() module.save()

View file

@ -11,9 +11,8 @@ from django.core.serializers.json import DjangoJSONEncoder
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
from plane.api.serializers import ProjectSerializer
from plane.app.permissions import ProjectBasePermission
# Module imports # Module imports
from plane.db.models import ( from plane.db.models import (
@ -31,16 +30,44 @@ from plane.db.models import (
from plane.bgtasks.webhook_task import model_activity, webhook_activity from plane.bgtasks.webhook_task import model_activity, webhook_activity
from .base import BaseAPIView from .base import BaseAPIView
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.api.serializers import (
ProjectSerializer,
ProjectCreateSerializer,
ProjectUpdateSerializer,
)
from plane.app.permissions import ProjectBasePermission
from plane.utils.openapi import (
project_docs,
PROJECT_ID_PARAMETER,
PROJECT_PK_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
create_paginated_response,
# Request Examples
PROJECT_CREATE_EXAMPLE,
PROJECT_UPDATE_EXAMPLE,
# Response Examples
PROJECT_EXAMPLE,
PROJECT_NOT_FOUND_RESPONSE,
WORKSPACE_NOT_FOUND_RESPONSE,
PROJECT_NAME_TAKEN_RESPONSE,
DELETED_RESPONSE,
ARCHIVED_RESPONSE,
UNARCHIVED_RESPONSE,
)
class ProjectAPIEndpoint(BaseAPIView): class ProjectListCreateAPIEndpoint(BaseAPIView):
"""Project Endpoints to create, update, list, retrieve and delete endpoint""" """Project List and Create Endpoint"""
serializer_class = ProjectSerializer serializer_class = ProjectSerializer
model = Project model = Project
webhook_event = "project" webhook_event = "project"
permission_classes = [ProjectBasePermission] permission_classes = [ProjectBasePermission]
use_read_replica = True
def get_queryset(self): def get_queryset(self):
return ( return (
@ -104,42 +131,87 @@ class ProjectAPIEndpoint(BaseAPIView):
.distinct() .distinct()
) )
def get(self, request, slug, pk=None): @project_docs(
if pk is None: operation_id="list_projects",
sort_order_query = ProjectMember.objects.filter( summary="List or retrieve projects",
member=request.user, description="Retrieve all projects in a workspace or get details of a specific project.",
project_id=OuterRef("pk"), parameters=[
workspace__slug=self.kwargs.get("slug"), CURSOR_PARAMETER,
is_active=True, PER_PAGE_PARAMETER,
).values("sort_order") ORDER_BY_PARAMETER,
projects = ( FIELDS_PARAMETER,
self.get_queryset() EXPAND_PARAMETER,
.annotate(sort_order=Subquery(sort_order_query)) ],
.prefetch_related( responses={
Prefetch( 200: create_paginated_response(
"project_projectmember", ProjectSerializer,
queryset=ProjectMember.objects.filter( "PaginatedProjectResponse",
workspace__slug=slug, is_active=True "Paginated list of projects",
).select_related("member"), "Paginated Projects",
) ),
) 404: PROJECT_NOT_FOUND_RESPONSE,
.order_by(request.GET.get("order_by", "sort_order")) },
) )
return self.paginate( def get(self, request, slug):
request=request, """List projects
queryset=(projects),
on_results=lambda projects: ProjectSerializer(
projects, many=True, fields=self.fields, expand=self.expand
).data,
)
project = self.get_queryset().get(workspace__slug=slug, pk=pk)
serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
Retrieve all projects in a workspace or get details of a specific project.
Returns projects ordered by user's custom sort order with member information.
"""
sort_order_query = ProjectMember.objects.filter(
member=request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
is_active=True,
).values("sort_order")
projects = (
self.get_queryset()
.annotate(sort_order=Subquery(sort_order_query))
.prefetch_related(
Prefetch(
"project_projectmember",
queryset=ProjectMember.objects.filter(
workspace__slug=slug, is_active=True
).select_related("member"),
)
)
.order_by(request.GET.get("order_by", "sort_order"))
)
return self.paginate(
request=request,
queryset=(projects),
on_results=lambda projects: ProjectSerializer(
projects, many=True, fields=self.fields, expand=self.expand
).data,
)
@project_docs(
operation_id="create_project",
summary="Create project",
description="Create a new project in the workspace with default states and member assignments.",
request=OpenApiRequest(
request=ProjectCreateSerializer,
examples=[PROJECT_CREATE_EXAMPLE],
),
responses={
201: OpenApiResponse(
description="Project created successfully",
response=ProjectSerializer,
examples=[PROJECT_EXAMPLE],
),
404: WORKSPACE_NOT_FOUND_RESPONSE,
409: PROJECT_NAME_TAKEN_RESPONSE,
},
)
def post(self, request, slug): def post(self, request, slug):
"""Create project
Create a new project in the workspace with default states and member assignments.
Automatically adds the creator as admin and sets up default workflow states.
"""
try: try:
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
serializer = ProjectSerializer( serializer = ProjectCreateSerializer(
data={**request.data}, context={"workspace_id": workspace.id} data={**request.data}, context={"workspace_id": workspace.id}
) )
if serializer.is_valid(): if serializer.is_valid():
@ -147,25 +219,25 @@ class ProjectAPIEndpoint(BaseAPIView):
# Add the user as Administrator to the project # Add the user as Administrator to the project
_ = ProjectMember.objects.create( _ = ProjectMember.objects.create(
project_id=serializer.data["id"], member=request.user, role=20 project_id=serializer.instance.id, member=request.user, role=20
) )
# Also create the issue property for the user # Also create the issue property for the user
_ = IssueUserProperty.objects.create( _ = IssueUserProperty.objects.create(
project_id=serializer.data["id"], user=request.user project_id=serializer.instance.id, user=request.user
) )
if serializer.data["project_lead"] is not None and str( if serializer.instance.project_lead is not None and str(
serializer.data["project_lead"] serializer.instance.project_lead
) != str(request.user.id): ) != str(request.user.id):
ProjectMember.objects.create( ProjectMember.objects.create(
project_id=serializer.data["id"], project_id=serializer.instance.id,
member_id=serializer.data["project_lead"], member_id=serializer.instance.project_lead,
role=20, role=20,
) )
# Also create the issue property for the user # Also create the issue property for the user
IssueUserProperty.objects.create( IssueUserProperty.objects.create(
project_id=serializer.data["id"], project_id=serializer.instance.id,
user_id=serializer.data["project_lead"], user_id=serializer.instance.project_lead,
) )
# Default states # Default states
@ -219,7 +291,7 @@ class ProjectAPIEndpoint(BaseAPIView):
] ]
) )
project = self.get_queryset().filter(pk=serializer.data["id"]).first() project = self.get_queryset().filter(pk=serializer.instance.id).first()
# Model activity # Model activity
model_activity.delay( model_activity.delay(
@ -251,7 +323,131 @@ class ProjectAPIEndpoint(BaseAPIView):
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,
) )
class ProjectDetailAPIEndpoint(BaseAPIView):
"""Project Endpoints to update, retrieve and delete endpoint"""
serializer_class = ProjectSerializer
model = Project
webhook_event = "project"
permission_classes = [ProjectBasePermission]
use_read_replica = True
def get_queryset(self):
return (
Project.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(
Q(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
| Q(network=2)
)
.select_related(
"workspace", "workspace__owner", "default_assignee", "project_lead"
)
.annotate(
is_member=Exists(
ProjectMember.objects.filter(
member=self.request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
is_active=True,
)
)
)
.annotate(
total_members=ProjectMember.objects.filter(
project_id=OuterRef("id"), member__is_bot=False, is_active=True
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
total_cycles=Cycle.objects.filter(project_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
total_modules=Module.objects.filter(project_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
member_role=ProjectMember.objects.filter(
project_id=OuterRef("pk"),
member_id=self.request.user.id,
is_active=True,
).values("role")
)
.annotate(
is_deployed=Exists(
DeployBoard.objects.filter(
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
.distinct()
)
@project_docs(
operation_id="retrieve_project",
summary="Retrieve project",
description="Retrieve details of a specific project.",
parameters=[
PROJECT_PK_PARAMETER,
],
responses={
200: OpenApiResponse(
description="Project details",
response=ProjectSerializer,
examples=[PROJECT_EXAMPLE],
),
404: PROJECT_NOT_FOUND_RESPONSE,
},
)
def get(self, request, slug, pk):
"""Retrieve project
Retrieve details of a specific project.
"""
project = self.get_queryset().get(workspace__slug=slug, pk=pk)
serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
@project_docs(
operation_id="update_project",
summary="Update project",
description="Partially update an existing project's properties like name, description, or settings.",
parameters=[
PROJECT_PK_PARAMETER,
],
request=OpenApiRequest(
request=ProjectUpdateSerializer,
examples=[PROJECT_UPDATE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Project updated successfully",
response=ProjectSerializer,
examples=[PROJECT_EXAMPLE],
),
404: PROJECT_NOT_FOUND_RESPONSE,
409: PROJECT_NAME_TAKEN_RESPONSE,
},
)
def patch(self, request, slug, pk): def patch(self, request, slug, pk):
"""Update project
Partially update an existing project's properties like name, description, or settings.
Tracks changes in model activity logs for audit purposes.
"""
try: try:
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk) project = Project.objects.get(pk=pk)
@ -267,7 +463,7 @@ class ProjectAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
serializer = ProjectSerializer( serializer = ProjectUpdateSerializer(
project, project,
data={**request.data, "intake_view": intake_view}, data={**request.data, "intake_view": intake_view},
context={"workspace_id": workspace.id}, context={"workspace_id": workspace.id},
@ -287,7 +483,7 @@ class ProjectAPIEndpoint(BaseAPIView):
is_default=True, is_default=True,
) )
project = self.get_queryset().filter(pk=serializer.data["id"]).first() project = self.get_queryset().filter(pk=serializer.instance.id).first()
model_activity.delay( model_activity.delay(
model_name="project", model_name="project",
@ -318,7 +514,23 @@ class ProjectAPIEndpoint(BaseAPIView):
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,
) )
@project_docs(
operation_id="delete_project",
summary="Delete project",
description="Permanently remove a project and all its associated data from the workspace.",
parameters=[
PROJECT_PK_PARAMETER,
],
responses={
204: DELETED_RESPONSE,
},
)
def delete(self, request, slug, pk): def delete(self, request, slug, pk):
"""Delete project
Permanently remove a project and all its associated data from the workspace.
Only admins can delete projects and the action cannot be undone.
"""
project = Project.objects.get(pk=pk, workspace__slug=slug) project = Project.objects.get(pk=pk, workspace__slug=slug)
# Delete the user favorite cycle # Delete the user favorite cycle
UserFavorite.objects.filter( UserFavorite.objects.filter(
@ -342,16 +554,52 @@ class ProjectAPIEndpoint(BaseAPIView):
class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView): class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView):
"""Project Archive and Unarchive Endpoint"""
permission_classes = [ProjectBasePermission] permission_classes = [ProjectBasePermission]
@project_docs(
operation_id="archive_project",
summary="Archive project",
description="Move a project to archived status, hiding it from active project lists.",
parameters=[
PROJECT_ID_PARAMETER,
],
request={},
responses={
204: ARCHIVED_RESPONSE,
},
)
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
"""Archive project
Move a project to archived status, hiding it from active project lists.
Archived projects remain accessible but are excluded from regular workflows.
"""
project = Project.objects.get(pk=project_id, workspace__slug=slug) project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = timezone.now() project.archived_at = timezone.now()
project.save() project.save()
UserFavorite.objects.filter(workspace__slug=slug, project=project_id).delete() UserFavorite.objects.filter(workspace__slug=slug, project=project_id).delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@project_docs(
operation_id="unarchive_project",
summary="Unarchive project",
description="Restore an archived project to active status, making it available in regular workflows.",
parameters=[
PROJECT_ID_PARAMETER,
],
request={},
responses={
204: UNARCHIVED_RESPONSE,
},
)
def delete(self, request, slug, project_id): def delete(self, request, slug, project_id):
"""Unarchive project
Restore an archived project to active status, making it available in regular workflows.
The project will reappear in active project lists and become fully functional.
"""
project = Project.objects.get(pk=project_id, workspace__slug=slug) project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = None project.archived_at = None
project.save() project.save()

View file

@ -4,19 +4,41 @@ from django.db import IntegrityError
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
# Module imports
from plane.api.serializers import StateSerializer from plane.api.serializers import StateSerializer
from plane.app.permissions import ProjectEntityPermission from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Issue, State from plane.db.models import Issue, State
# Module imports
from .base import BaseAPIView from .base import BaseAPIView
from plane.utils.openapi import (
state_docs,
STATE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
create_paginated_response,
# Request Examples
STATE_CREATE_EXAMPLE,
STATE_UPDATE_EXAMPLE,
# Response Examples
STATE_EXAMPLE,
INVALID_REQUEST_RESPONSE,
STATE_NAME_EXISTS_RESPONSE,
DELETED_RESPONSE,
STATE_CANNOT_DELETE_RESPONSE,
EXTERNAL_ID_EXISTS_RESPONSE,
)
class StateAPIEndpoint(BaseAPIView): class StateListCreateAPIEndpoint(BaseAPIView):
"""State List and Create Endpoint"""
serializer_class = StateSerializer serializer_class = StateSerializer
model = State model = State
permission_classes = [ProjectEntityPermission] permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self): def get_queryset(self):
return ( return (
@ -33,7 +55,30 @@ class StateAPIEndpoint(BaseAPIView):
.distinct() .distinct()
) )
@state_docs(
operation_id="create_state",
summary="Create state",
description="Create a new workflow state for a project with specified name, color, and group.",
request=OpenApiRequest(
request=StateSerializer,
examples=[STATE_CREATE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="State created",
response=StateSerializer,
examples=[STATE_EXAMPLE],
),
400: INVALID_REQUEST_RESPONSE,
409: STATE_NAME_EXISTS_RESPONSE,
},
)
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
"""Create state
Create a new workflow state for a project with specified name, color, and group.
Supports external ID tracking for integration purposes.
"""
try: try:
serializer = StateSerializer( serializer = StateSerializer(
data=request.data, context={"project_id": project_id} data=request.data, context={"project_id": project_id}
@ -80,14 +125,31 @@ class StateAPIEndpoint(BaseAPIView):
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,
) )
def get(self, request, slug, project_id, state_id=None): @state_docs(
if state_id: operation_id="list_states",
serializer = StateSerializer( summary="List states",
self.get_queryset().get(pk=state_id), description="Retrieve all workflow states for a project.",
fields=self.fields, parameters=[
expand=self.expand, CURSOR_PARAMETER,
) PER_PAGE_PARAMETER,
return Response(serializer.data, status=status.HTTP_200_OK) FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
responses={
200: create_paginated_response(
StateSerializer,
"PaginatedStateResponse",
"Paginated list of states",
"Paginated States",
),
},
)
def get(self, request, slug, project_id):
"""List states
Retrieve all workflow states for a project.
Returns paginated results when listing all states.
"""
return self.paginate( return self.paginate(
request=request, request=request,
queryset=(self.get_queryset()), queryset=(self.get_queryset()),
@ -96,7 +158,76 @@ class StateAPIEndpoint(BaseAPIView):
).data, ).data,
) )
class StateDetailAPIEndpoint(BaseAPIView):
"""State Detail Endpoint"""
serializer_class = StateSerializer
model = State
permission_classes = [ProjectEntityPermission]
use_read_replica = True
def get_queryset(self):
return (
State.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(is_triage=False)
.filter(project__archived_at__isnull=True)
.select_related("project")
.select_related("workspace")
.distinct()
)
@state_docs(
operation_id="retrieve_state",
summary="Retrieve state",
description="Retrieve details of a specific state.",
parameters=[
STATE_ID_PARAMETER,
],
responses={
200: OpenApiResponse(
description="State retrieved",
response=StateSerializer,
examples=[STATE_EXAMPLE],
),
},
)
def get(self, request, slug, project_id, state_id):
"""Retrieve state
Retrieve details of a specific state.
Returns paginated results when listing all states.
"""
serializer = StateSerializer(
self.get_queryset().get(pk=state_id),
fields=self.fields,
expand=self.expand,
)
return Response(serializer.data, status=status.HTTP_200_OK)
@state_docs(
operation_id="delete_state",
summary="Delete state",
description="Permanently remove a workflow state from a project. Default states and states with existing work items cannot be deleted.",
parameters=[
STATE_ID_PARAMETER,
],
responses={
204: DELETED_RESPONSE,
400: STATE_CANNOT_DELETE_RESPONSE,
},
)
def delete(self, request, slug, project_id, state_id): def delete(self, request, slug, project_id, state_id):
"""Delete state
Permanently remove a workflow state from a project.
Default states and states with existing work items cannot be deleted.
"""
state = State.objects.get( state = State.objects.get(
is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug
) )
@ -119,7 +250,33 @@ class StateAPIEndpoint(BaseAPIView):
state.delete() state.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, project_id, state_id=None): @state_docs(
operation_id="update_state",
summary="Update state",
description="Partially update an existing workflow state's properties like name, color, or group.",
parameters=[
STATE_ID_PARAMETER,
],
request=OpenApiRequest(
request=StateSerializer,
examples=[STATE_UPDATE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="State updated",
response=StateSerializer,
examples=[STATE_EXAMPLE],
),
400: INVALID_REQUEST_RESPONSE,
409: EXTERNAL_ID_EXISTS_RESPONSE,
},
)
def patch(self, request, slug, project_id, state_id):
"""Update state
Partially update an existing workflow state's properties like name, color, or group.
Validates external ID uniqueness if provided.
"""
state = State.objects.get( state = State.objects.get(
workspace__slug=slug, project_id=project_id, pk=state_id workspace__slug=slug, project_id=project_id, pk=state_id
) )

View file

@ -0,0 +1,37 @@
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from drf_spectacular.utils import OpenApiResponse
# Module imports
from plane.api.serializers import UserLiteSerializer
from plane.api.views.base import BaseAPIView
from plane.db.models import User
from plane.utils.openapi.decorators import user_docs
from plane.utils.openapi import USER_EXAMPLE
class UserEndpoint(BaseAPIView):
serializer_class = UserLiteSerializer
model = User
@user_docs(
operation_id="get_current_user",
summary="Get current user",
description="Retrieve the authenticated user's profile information including basic details.",
responses={
200: OpenApiResponse(
description="Current user profile",
response=UserLiteSerializer,
examples=[USER_EXAMPLE],
),
},
)
def get(self, request):
"""Get current user
Retrieve the authenticated user's profile information including basic details.
Returns user data based on the current authentication context.
"""
serializer = UserLiteSerializer(request.user)
return Response(serializer.data, status=status.HTTP_200_OK)

View file

@ -75,12 +75,12 @@ class ProjectEntityPermission(BasePermission):
return False return False
# Handle requests based on project__identifier # Handle requests based on project__identifier
if hasattr(view, "project__identifier") and view.project__identifier: if hasattr(view, "project_identifier") and view.project_identifier:
if request.method in SAFE_METHODS: if request.method in SAFE_METHODS:
return ProjectMember.objects.filter( return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug, workspace__slug=view.workspace_slug,
member=request.user, member=request.user,
project__identifier=view.project__identifier, project__identifier=view.project_identifier,
is_active=True, is_active=True,
).exists() ).exists()

View file

@ -1,3 +1,5 @@
from lxml import html
# Django imports # Django imports
from django.utils import timezone from django.utils import timezone
@ -21,7 +23,6 @@ from plane.db.models import (
) )
from plane.utils.content_validator import ( from plane.utils.content_validator import (
validate_html_content, validate_html_content,
validate_json_content,
validate_binary_data, validate_binary_data,
) )
from plane.app.permissions import ROLE from plane.app.permissions import ROLE
@ -74,20 +75,24 @@ class DraftIssueCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date") raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security # Validate description content for security
if "description" in attrs and attrs["description"]:
is_valid, error_msg = validate_json_content(attrs["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if "description_html" in attrs and attrs["description_html"]: if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg = validate_html_content(attrs["description_html"]) is_valid, error_msg, sanitized_html = validate_html_content(
attrs["description_html"]
)
if not is_valid: if not is_valid:
raise serializers.ValidationError({"description_html": error_msg}) raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the attrs with sanitized HTML if available
if sanitized_html is not None:
attrs["description_html"] = sanitized_html
if "description_binary" in attrs and attrs["description_binary"]: if "description_binary" in attrs and attrs["description_binary"]:
is_valid, error_msg = validate_binary_data(attrs["description_binary"]) is_valid, error_msg = validate_binary_data(attrs["description_binary"])
if not is_valid: if not is_valid:
raise serializers.ValidationError({"description_binary": error_msg}) raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
# Validate assignees are from project # Validate assignees are from project
if attrs.get("assignee_ids", []): if attrs.get("assignee_ids", []):
@ -258,7 +263,7 @@ class DraftIssueCreateSerializer(BaseSerializer):
DraftIssueLabel.objects.bulk_create( DraftIssueLabel.objects.bulk_create(
[ [
DraftIssueLabel( DraftIssueLabel(
label=label, label_id=label,
draft_issue=instance, draft_issue=instance,
workspace_id=workspace_id, workspace_id=workspace_id,
project_id=project_id, project_id=project_id,

View file

@ -1,3 +1,5 @@
from lxml import html
# Django imports # Django imports
from django.utils import timezone from django.utils import timezone
from django.core.validators import URLValidator from django.core.validators import URLValidator
@ -41,7 +43,6 @@ from plane.db.models import (
) )
from plane.utils.content_validator import ( from plane.utils.content_validator import (
validate_html_content, validate_html_content,
validate_json_content,
validate_binary_data, validate_binary_data,
) )
@ -126,20 +127,24 @@ class IssueCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date") raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security # Validate description content for security
if "description" in attrs and attrs["description"]:
is_valid, error_msg = validate_json_content(attrs["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if "description_html" in attrs and attrs["description_html"]: if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg = validate_html_content(attrs["description_html"]) is_valid, error_msg, sanitized_html = validate_html_content(
attrs["description_html"]
)
if not is_valid: if not is_valid:
raise serializers.ValidationError({"description_html": error_msg}) raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the attrs with sanitized HTML if available
if sanitized_html is not None:
attrs["description_html"] = sanitized_html
if "description_binary" in attrs and attrs["description_binary"]: if "description_binary" in attrs and attrs["description_binary"]:
is_valid, error_msg = validate_binary_data(attrs["description_binary"]) is_valid, error_msg = validate_binary_data(attrs["description_binary"])
if not is_valid: if not is_valid:
raise serializers.ValidationError({"description_binary": error_msg}) raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
# Validate assignees are from project # Validate assignees are from project
if attrs.get("assignee_ids", []): if attrs.get("assignee_ids", []):
@ -906,9 +911,14 @@ class IssueLiteSerializer(DynamicBaseSerializer):
class IssueDetailSerializer(IssueSerializer): class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField() description_html = serializers.CharField()
is_subscribed = serializers.BooleanField(read_only=True) is_subscribed = serializers.BooleanField(read_only=True)
is_intake = serializers.BooleanField(read_only=True)
class Meta(IssueSerializer.Meta): class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + ["description_html", "is_subscribed"] fields = IssueSerializer.Meta.fields + [
"description_html",
"is_subscribed",
"is_intake",
]
read_only_fields = fields read_only_fields = fields

View file

@ -7,7 +7,6 @@ from .base import BaseSerializer
from plane.utils.content_validator import ( from plane.utils.content_validator import (
validate_binary_data, validate_binary_data,
validate_html_content, validate_html_content,
validate_json_content,
) )
from plane.db.models import ( from plane.db.models import (
Page, Page,
@ -229,23 +228,13 @@ class PageBinaryUpdateSerializer(serializers.Serializer):
return value return value
# Use the validation function from utils # Use the validation function from utils
is_valid, error_message = validate_html_content(value) is_valid, error_message, sanitized_html = validate_html_content(value)
if not is_valid: if not is_valid:
raise serializers.ValidationError(error_message) raise serializers.ValidationError(error_message)
return value # Return sanitized HTML if available, otherwise return original
return sanitized_html if sanitized_html is not None else value
def validate_description(self, value):
"""Validate the JSON description"""
if not value:
return value
# Use the validation function from utils
is_valid, error_message = validate_json_content(value)
if not is_valid:
raise serializers.ValidationError(error_message)
return value
def update(self, instance, validated_data): def update(self, instance, validated_data):
"""Update the page instance with validated data""" """Update the page instance with validated data"""

View file

@ -15,7 +15,6 @@ from plane.db.models import (
) )
from plane.utils.content_validator import ( from plane.utils.content_validator import (
validate_html_content, validate_html_content,
validate_json_content,
validate_binary_data, validate_binary_data,
) )
@ -65,27 +64,18 @@ class ProjectSerializer(BaseSerializer):
def validate(self, data): def validate(self, data):
# Validate description content for security # Validate description content for security
if "description" in data and data["description"]:
# For Project, description might be text field, not JSON
if isinstance(data["description"], dict):
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if "description_text" in data and data["description_text"]:
is_valid, error_msg = validate_json_content(data["description_text"])
if not is_valid:
raise serializers.ValidationError({"description_text": error_msg})
if "description_html" in data and data["description_html"]: if "description_html" in data and data["description_html"]:
if isinstance(data["description_html"], dict): is_valid, error_msg, sanitized_html = validate_html_content(
is_valid, error_msg = validate_json_content(data["description_html"]) str(data["description_html"])
else: )
is_valid, error_msg = validate_html_content( # Update the data with sanitized HTML if available
str(data["description_html"]) if sanitized_html is not None:
) data["description_html"] = sanitized_html
if not is_valid: if not is_valid:
raise serializers.ValidationError({"description_html": error_msg}) raise serializers.ValidationError(
{"error": "html content is not valid"}
)
return data return data

View file

@ -26,7 +26,6 @@ from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.utils.url import contains_url from plane.utils.url import contains_url
from plane.utils.content_validator import ( from plane.utils.content_validator import (
validate_html_content, validate_html_content,
validate_json_content,
validate_binary_data, validate_binary_data,
) )
@ -319,20 +318,24 @@ class StickySerializer(BaseSerializer):
def validate(self, data): def validate(self, data):
# Validate description content for security # Validate description content for security
if "description" in data and data["description"]:
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if "description_html" in data and data["description_html"]: if "description_html" in data and data["description_html"]:
is_valid, error_msg = validate_html_content(data["description_html"]) is_valid, error_msg, sanitized_html = validate_html_content(
data["description_html"]
)
if not is_valid: if not is_valid:
raise serializers.ValidationError({"description_html": error_msg}) 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"]: if "description_binary" in data and data["description_binary"]:
is_valid, error_msg = validate_binary_data(data["description_binary"]) is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid: if not is_valid:
raise serializers.ValidationError({"description_binary": error_msg}) raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
return data return data

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