Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0a4adc55b | |||
| 1dabc632bf | |||
| 761c999e0c | |||
| 32fb88ab24 | |||
| 03a2be84b7 | |||
| c62930ebcf | |||
| f1d567accc | |||
| 62b2d1b207 | |||
| da41f14a05 | |||
| aea66f53f4 | |||
| 45b4fc8932 | |||
| a8a16c8ba0 | |||
| ac11c3ef79 | |||
| 13db2f883f | |||
| bbf14fba31 | |||
| db3c8f27dc | |||
| 39325d28a6 | |||
| c21d2c6fb3 | |||
| e6b9d4c9ba | |||
| 6023e8cfc8 | |||
| 77c4b9c774 | |||
| 8a2579ce9b | |||
| 7c2fc2dd7f | |||
| d1db13c3a7 | |||
| cf696d200d | |||
| bb128e3e16 | |||
| 63fac3b8c4 | |||
| 587fe76032 | |||
| a18d90da86 | |||
| febf98ea54 | |||
| 5747dc6fd8 | |||
| d83944cc8d | |||
| 799b9cbfc5 | |||
| a01b51fca5 | |||
| 00a51f5e6a | |||
| b73d6344ad | |||
| f0ec84661d | |||
| d8ed19f204 | |||
| 9fa707b260 | |||
| d7c80885fd | |||
| 9851fe0b8f | |||
| 5e237938ff | |||
| f0468a9173 | |||
| c53968a7f8 | |||
| 97b4abd693 | |||
| 130ba5ee6c | |||
| 113bba46ea | |||
| ce401c723e | |||
| 5396d438a3 | |||
| 942d2b98ef | |||
| d94a269451 | |||
| 54b80e91eb | |||
| 6e033f9fdb | |||
| f3c7c057b4 | |||
| d91b5a274b | |||
| 5a7d1ebd65 | |||
| 04d4490293 | |||
| d9695afcdc | |||
| c3c7c72aff | |||
| 9d3b5d9da7 | |||
| 1faf06c755 | |||
| 72b6453f6f | |||
| 428cb478b1 | |||
| e972989522 | |||
| 588dc2927e | |||
| 6627282bc5 | |||
| d7c12f9730 | |||
| 2e429e5198 | |||
| c3a9f99789 | |||
| 7902805635 | |||
| 7b1f5a47f5 | |||
| 71b0d30afb | |||
| 9a7696acac | |||
| cc7982ca14 | |||
| fc66fba5aa | |||
| 5af0f58aa9 | |||
| 98253e3085 | |||
| 60da3df508 | |||
| d20247e976 | |||
| 7fb6696c67 | |||
| be8836642a | |||
| 2578c5311b | |||
| a75301d6c6 | |||
| 351344ecbb | |||
| a58642ed10 | |||
| a9d688f290 | |||
| da870a1513 | |||
| c5542438a1 | |||
| 41abaffc6e | |||
| 9ee73d57ef | |||
| 779f5e272f | |||
| bf52fa22be | |||
| 5c9f2a17c2 | |||
| 9425c66eb5 | |||
| 2a978e3ac0 | |||
| 8c23fdd1d8 | |||
| a77af4e67e | |||
| b783f25bfa | |||
| 95d121ce38 | |||
| 318c993082 | |||
| 6c984e18ae | |||
| ec44b63027 | |||
| 1548288e95 | |||
| 81cea3256a | |||
| 07f269e7f3 | |||
| ce69644d53 |
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: pr-description
|
||||
description: Generate a PR description following the project's GitHub PR template. Analyzes the current branch's changes against the base branch to produce a complete, filled-out PR description.
|
||||
user_invocable: true
|
||||
---
|
||||
|
||||
# PR Description Generator
|
||||
|
||||
Generate a pull request description based on the project's PR template at `.github/pull_request_template.md`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Determine the base branch**: Prefer the PR's actual `baseRefName` (via `gh pr view <PR> --json baseRefName`) when a PR exists. Otherwise default by intent — feature PRs target `preview`, release PRs target `master`. If still ambiguous, ask the user.
|
||||
|
||||
2. **Analyze changes**: Run the following to understand what changed:
|
||||
- `git log <base>...HEAD --oneline` to see all commits on this branch
|
||||
- `git diff <base>...HEAD --stat` to see which files changed
|
||||
- `git diff <base>...HEAD` to read the actual diff (use `--no-color`)
|
||||
- If the diff is very large, focus on the most important files first
|
||||
|
||||
3. **Fill out the PR template** with the following sections:
|
||||
|
||||
### Description
|
||||
|
||||
Write a clear, concise summary of what the PR does and why. Focus on the "what" and "why", not line-by-line changes. Mention any important implementation decisions.
|
||||
|
||||
### Type of Change
|
||||
|
||||
Check the appropriate box(es) based on the changes:
|
||||
- Bug fix (non-breaking change which fixes an issue)
|
||||
- Feature (non-breaking change which adds functionality)
|
||||
- Improvement (non-breaking change that improves existing functionality)
|
||||
- Code refactoring
|
||||
- Performance improvements
|
||||
- Documentation update
|
||||
|
||||
### Screenshots and Media
|
||||
|
||||
Leave this section for the user to fill in, preserving the existing placeholder comment from `.github/pull_request_template.md` verbatim rather than introducing different text.
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
Based on the code changes, suggest specific test scenarios that should be verified. Be concrete (e.g., "Navigate to project settings and verify the new toggle works") rather than generic.
|
||||
|
||||
### References
|
||||
- If commit messages or branch name reference a work item identifier (e.g., `WEB-1234`), include it
|
||||
- If the user provides a linked issue, include it
|
||||
- If Sentry issue links or IDs (e.g., `SENTRY-ABC123`, Sentry URLs) were mentioned earlier in the conversation, include them as references
|
||||
|
||||
4. **Output format**: Print the filled-out markdown template so the user can copy it directly. Do NOT wrap it in a code fence — output the raw markdown.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Keep the description concise but informative
|
||||
- Use bullet points for multiple changes
|
||||
- Focus on user-facing impact, not implementation details
|
||||
- If the branch has a Plane work item ID in its name (e.g., `WEB-1234`), reference it
|
||||
- Don't fabricate test scenarios that aren't relevant to the actual changes
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
name: release-notes
|
||||
description: "Generate release notes for a Plane release PR in `makeplane/plane` (semver, e.g. `release: vX.Y.Z`). Reads PR commits, filters out noise, categorizes by conventional-commit type, optionally enriches via Plane MCP, and writes the result as the PR description."
|
||||
user_invocable: true
|
||||
---
|
||||
|
||||
# Release Notes Generator
|
||||
|
||||
Generate structured release notes from a Plane release PR by parsing its commit list, then update the PR description.
|
||||
|
||||
## Versioning
|
||||
|
||||
Plane community uses **semver** (`vX.Y.Z`, major.minor.patch) for releases.
|
||||
|
||||
- PR title format: `release: vX.Y.Z`
|
||||
- Source branch: `canary`
|
||||
- Target branch: `master`
|
||||
|
||||
## When to Use
|
||||
|
||||
- User links/mentions a Plane release PR (e.g. `release: v1.3.0`) and asks for release notes
|
||||
- User asks to "create release notes" / "update PR description" for a release PR in `makeplane/plane`
|
||||
- The branch is named `canary` or `release/x.y.z` and the base is `master`
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Fetch commits
|
||||
|
||||
```bash
|
||||
gh pr view <PR_NUM> --json title,body,baseRefName,headRefName,commits \
|
||||
--jq '.commits[] | .messageHeadline + "\n---BODY---\n" + .messageBody + "\n===END==="'
|
||||
```
|
||||
|
||||
For a quick scan first:
|
||||
|
||||
```bash
|
||||
gh pr view <PR_NUM> --json commits \
|
||||
--jq '.commits[] | {oid: .oid[0:10], message: .messageHeadline}'
|
||||
```
|
||||
|
||||
### 2. Filter out noise
|
||||
|
||||
**Always exclude** these commits — mechanical, not user-facing:
|
||||
|
||||
| Pattern | Reason |
|
||||
| -------------------------------------------- | -------------- |
|
||||
| `fix: merge conflicts` | Merge artifact |
|
||||
| `Merge branch '...' of github.com:...` | Merge artifact |
|
||||
| `Revert "..."` (when immediately re-applied) | Internal churn |
|
||||
|
||||
### 3. Parse work item IDs
|
||||
|
||||
Most meaningful commits begin with a Plane work item identifier in brackets:
|
||||
|
||||
- `[WEB-XXXX]` — web/frontend product items
|
||||
- `[SILO-XXXX]` — Silo (integrations: Slack, GitHub, GitLab, Jira/Linear)
|
||||
- `[MOBILE-XXXX]`, `[API-XXXX]`, etc.
|
||||
|
||||
Always preserve these IDs in the release notes — they let readers click through to the source ticket.
|
||||
|
||||
### 4. (Optional) Enrich via Plane MCP
|
||||
|
||||
For larger features where the commit headline is terse, fetch the work item:
|
||||
|
||||
```text
|
||||
mcp__plane__retrieve_work_item_by_identifier(project_identifier="WEB", issue_identifier=6874)
|
||||
```
|
||||
|
||||
Use the returned `name` and `description_stripped` to flesh out the bullet. Skip this for routine fixes — commit body is usually enough. Don't enrich every item (slow + work item descriptions are often empty).
|
||||
|
||||
### 5. Categorize by conventional-commit type
|
||||
|
||||
| Commit prefix | Section |
|
||||
| -------------------------------- | ------------------- |
|
||||
| `feat:`, `feat(scope):` | ✨ New Features |
|
||||
| `fix:`, `fix(scope):` | 🐛 Bug Fixes |
|
||||
| `refactor:` | 🔧 Refactor & Chore |
|
||||
| `chore:`, `chore(scope):` | 🔧 Refactor & Chore |
|
||||
| `chore(deps):`, dependabot bumps | 📦 Dependencies |
|
||||
|
||||
### 6. Format
|
||||
|
||||
```markdown
|
||||
# Release vX.Y.Z
|
||||
|
||||
## ✨ New Features
|
||||
|
||||
- **<Short title>** — [WEB-XXXX] (#PR_NUM)
|
||||
Optional 1–2 sentence elaboration drawn from commit body.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
- **<Short title>** — [WEB-XXXX] (#PR_NUM)
|
||||
|
||||
## 🔧 Refactor & Chore
|
||||
|
||||
- **<Short title>** — [WEB-XXXX] (#PR_NUM)
|
||||
|
||||
## 📦 Dependencies
|
||||
|
||||
- Bump `<package>` X.Y.Z → A.B.C (#PR_NUM)
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Lead with a bold human-readable title (rewrite the commit subject if cryptic)
|
||||
- Always include the work item ID in brackets and the merge PR number in parens
|
||||
- Add a sub-line elaboration only when the commit body has substance worth surfacing (acceptance criteria, scope notes, gotchas like "behind feature flag", "requires migration", "requires Vercel setting")
|
||||
- Drop empty sections
|
||||
|
||||
### 7. Update the PR description
|
||||
|
||||
```bash
|
||||
gh pr edit <PR_NUM> --body "$(cat <<'EOF'
|
||||
<release notes markdown>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Always use a HEREDOC with single-quoted `'EOF'` so backticks/dollars in the notes are preserved.
|
||||
|
||||
## Quick Reference: end-to-end
|
||||
|
||||
```bash
|
||||
PR=2498
|
||||
gh pr view $PR --json commits --jq '.commits[] | .messageHeadline + "\n---\n" + .messageBody + "\n==="' > /tmp/commits.txt
|
||||
# read /tmp/commits.txt, filter, categorize, draft notes
|
||||
gh pr edit $PR --body "$(cat <<'EOF'
|
||||
... release notes ...
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- **Including `fix: merge conflicts`** — merge artifact, no functional content
|
||||
- **Dropping the work item ID** — readers rely on `[WEB-XXXX]` to navigate to the ticket
|
||||
- **Over-enriching with MCP lookups** — work item descriptions are often empty; commit body is usually richer
|
||||
- **Missing the merge PR number** — always include `(#NNNN)` from the commit subject so reviewers can audit the source PR
|
||||
- **Using `--body` without HEREDOC** — backticks/dollar signs get shell-interpreted and corrupt the notes
|
||||
- **Editing the title** — release PR titles are version markers; only edit the body
|
||||
|
||||
## Plane-Specific Conventions
|
||||
|
||||
- Release PRs go from `canary` → `master`
|
||||
- PR title format: `release: vX.Y.Z` semver (major.minor.patch)
|
||||
- Commits coming from feature branches always carry a work item ID; commits without one are usually infra/chores
|
||||
@@ -1,7 +0,0 @@
|
||||
[codespell]
|
||||
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
|
||||
skip = .git*,*.svg,i18n,*-lock.yaml,*.css,.codespellrc,migrations,*.js,*.map,*.mjs
|
||||
check-hidden = true
|
||||
# ignore all CamelCase and camelCase
|
||||
ignore-regex = \b[A-Za-z][a-z]+[A-Z][a-zA-Z]+\b
|
||||
ignore-words-list = tread
|
||||
@@ -1,6 +1,6 @@
|
||||
contact_links:
|
||||
- name: Help and support
|
||||
about: Reach out to us on our Discord server or GitHub discussions.
|
||||
about: Reach out to us on our Forum or GitHub discussions.
|
||||
- name: Dedicated support
|
||||
url: mailto:support@plane.so
|
||||
about: Write to us if you'd like dedicated support using Plane
|
||||
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
branch_build_push_admin:
|
||||
name: Build-Push Admin Docker Image
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Admin Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
uses: makeplane/actions/build-push@v1.4.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Web Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
uses: makeplane/actions/build-push@v1.4.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Space Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
uses: makeplane/actions/build-push@v1.4.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Live Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
uses: makeplane/actions/build-push@v1.4.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Backend Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
uses: makeplane/actions/build-push@v1.4.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
@@ -252,7 +252,7 @@ jobs:
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Proxy Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
uses: makeplane/actions/build-push@v1.4.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
@@ -282,7 +282,7 @@ jobs:
|
||||
- branch_build_push_proxy
|
||||
steps:
|
||||
- name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Prepare AIO Assets
|
||||
id: prepare_aio_assets
|
||||
@@ -298,13 +298,13 @@ jobs:
|
||||
echo "AIO_BUILD_VERSION=${aio_version}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload AIO Assets
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
path: ./deployments/aio/community/dist
|
||||
name: aio-assets-dist
|
||||
|
||||
- name: AIO Build and Push
|
||||
uses: makeplane/actions/build-push@v1.1.0
|
||||
uses: makeplane/actions/build-push@v1.4.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
@@ -337,7 +337,7 @@ jobs:
|
||||
- branch_build_push_proxy
|
||||
steps:
|
||||
- name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Update Assets
|
||||
run: |
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
# sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deployments/cli/community/variables.env
|
||||
|
||||
- name: Upload Assets
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: community-assets
|
||||
path: |
|
||||
@@ -381,7 +381,7 @@ jobs:
|
||||
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Update Assets
|
||||
run: |
|
||||
@@ -391,12 +391,13 @@ jobs:
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2.1.0
|
||||
uses: softprops/action-gh-release@v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
|
||||
with:
|
||||
tag_name: ${{ env.REL_VERSION }}
|
||||
name: ${{ env.REL_VERSION }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
draft: false
|
||||
prerelease: ${{ env.IS_PRERELEASE }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -10,13 +10,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
- name: Get PR Branch version
|
||||
run: echo "PR_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
|
||||
|
||||
@@ -16,47 +16,27 @@ jobs:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
env:
|
||||
CODEQL_ACTION_FILE_COVERAGE_ON_PRS: "false"
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["python", "javascript"]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Use only 'java' to analyze code written in Java, Kotlin or both
|
||||
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# Codespell configuration is within .codespellrc
|
||||
---
|
||||
name: Codespell
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [preview]
|
||||
pull_request:
|
||||
branches: [preview]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
codespell:
|
||||
name: Check for spelling errors
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Annotate locations with typos
|
||||
uses: codespell-project/codespell-problem-matcher@v1
|
||||
- name: Codespell
|
||||
uses: codespell-project/actions-codespell@v2
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.22"
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
full_build_push:
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -63,23 +63,23 @@ jobs:
|
||||
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
steps:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver: ${{ env.BUILDX_DRIVER }}
|
||||
version: ${{ env.BUILDX_VERSION }}
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Build and Push to Docker Hub
|
||||
uses: docker/build-push-action@v6.9.0
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
with:
|
||||
context: .
|
||||
file: ./aio/Dockerfile-app
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
sudo apt-get install -y python3-pip
|
||||
pip3 install awscli
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@v2
|
||||
uses: tailscale/github-action@v4
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }}
|
||||
|
||||
@@ -8,8 +8,6 @@ on:
|
||||
types:
|
||||
- "opened"
|
||||
- "synchronize"
|
||||
- "ready_for_review"
|
||||
- "review_requested"
|
||||
- "reopened"
|
||||
|
||||
concurrency:
|
||||
@@ -46,7 +44,7 @@ jobs:
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
@@ -89,7 +87,7 @@ jobs:
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
@@ -97,7 +95,7 @@ jobs:
|
||||
pnpm-store-${{ runner.os }}-
|
||||
|
||||
- name: Restore Turbo cache
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
|
||||
@@ -112,21 +110,59 @@ jobs:
|
||||
run: pnpm turbo run build --affected
|
||||
|
||||
- name: Save Turbo cache
|
||||
uses: actions/cache/save@v4
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
|
||||
|
||||
# Lint and type checks depend on build artifacts
|
||||
check:
|
||||
name: ${{ matrix.task }}
|
||||
# Lint check - no build dependency, OxLint is a standalone Rust binary
|
||||
check-lint:
|
||||
name: check:lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
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@v6
|
||||
with:
|
||||
fetch-depth: 50
|
||||
filter: blob:none
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
- name: Enable Corepack and pnpm
|
||||
run: corepack enable pnpm
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-store-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run check:lint
|
||||
run: pnpm turbo run check:lint --affected
|
||||
|
||||
# Type check depends on build artifacts
|
||||
check-types:
|
||||
name: check:types
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
task: [check:lint, check:types]
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
@@ -149,7 +185,7 @@ jobs:
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
@@ -157,7 +193,7 @@ jobs:
|
||||
pnpm-store-${{ runner.os }}-
|
||||
|
||||
- name: Restore Turbo cache
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
|
||||
@@ -165,5 +201,5 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: pnpm turbo run ${{ matrix.task }} --affected
|
||||
- name: Run check:types
|
||||
run: pnpm turbo run check:types --affected
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Create PR on Sync
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "sync/**"
|
||||
|
||||
env:
|
||||
CURRENT_BRANCH: ${{ github.ref_name }}
|
||||
TARGET_BRANCH: "preview" # The target branch that you would like to merge changes like develop
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
|
||||
ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }}
|
||||
ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }}
|
||||
|
||||
jobs:
|
||||
create_pull_request:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for all branches and tags
|
||||
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config user.name "$ACCOUNT_USER_NAME"
|
||||
git config user.email "$ACCOUNT_USER_EMAIL"
|
||||
|
||||
- name: Setup GH CLI and Git Config
|
||||
run: |
|
||||
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install gh -y
|
||||
|
||||
- name: Create PR to Target Branch
|
||||
run: |
|
||||
# get all pull requests and check if there is already a PR
|
||||
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $CURRENT_BRANCH --state open --json number | jq '.[] | .number')
|
||||
if [ -n "$PR_EXISTS" ]; then
|
||||
echo "Pull Request already exists: $PR_EXISTS"
|
||||
else
|
||||
echo "Creating new pull request"
|
||||
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "${{ vars.SYNC_PR_TITLE }}" --body "")
|
||||
echo "Pull Request created: $PR_URL"
|
||||
fi
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Sync Repositories
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- preview
|
||||
|
||||
env:
|
||||
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
|
||||
|
||||
jobs:
|
||||
sync_changes:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup GH CLI
|
||||
run: |
|
||||
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install gh -y
|
||||
|
||||
- name: Push Changes to Target Repo
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
run: |
|
||||
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
|
||||
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
|
||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||
|
||||
git checkout $SOURCE_BRANCH
|
||||
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"sortTailwindcss": {
|
||||
"stylesheet": "packages/tailwind-config/index.css",
|
||||
"functions": ["cn", "clsx", "cva"]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["packages/codemods/**/*"],
|
||||
@@ -7,9 +13,5 @@
|
||||
"printWidth": 80
|
||||
}
|
||||
}
|
||||
],
|
||||
"plugins": ["@prettier/plugin-oxc"],
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "jsx-a11y", "import", "promise", "unicorn", "oxc"],
|
||||
"categories": {
|
||||
"correctness": "warn",
|
||||
"suspicious": "warn",
|
||||
"perf": "warn"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"es2024": true
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "18.3"
|
||||
},
|
||||
"jsx-a11y": {
|
||||
"polymorphicPropName": "as"
|
||||
}
|
||||
},
|
||||
"ignorePatterns": [
|
||||
".cache/**",
|
||||
".next/**",
|
||||
".react-router/**",
|
||||
".storybook/**",
|
||||
".turbo/**",
|
||||
".vite/**",
|
||||
"*.config.{js,mjs,cjs,ts}",
|
||||
"build/**",
|
||||
"coverage/**",
|
||||
"dist/**",
|
||||
"**/public/**",
|
||||
"storybook-static/**"
|
||||
],
|
||||
"rules": {
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"unicorn/filename-case": "off",
|
||||
"unicorn/no-null": "off",
|
||||
"unicorn/prevent-abbreviations": "off",
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"destructuredArrayIgnorePattern": "^_",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
- `pnpm dev` - Start all dev servers (web:3000, admin:3001)
|
||||
- `pnpm build` - Build all packages and apps
|
||||
- `pnpm check` - Run all checks (format, lint, types)
|
||||
- `pnpm check:lint` - ESLint across all packages
|
||||
- `pnpm check:lint` - OxLint across all packages
|
||||
- `pnpm check:types` - TypeScript type checking
|
||||
- `pnpm fix` - Auto-fix format and lint issues
|
||||
- `pnpm turbo run <command> --filter=<package>` - Target specific package/app
|
||||
@@ -15,8 +15,8 @@
|
||||
|
||||
- **Imports**: Use `workspace:*` for internal packages, `catalog:` for external deps
|
||||
- **TypeScript**: Strict mode enabled, all files must be typed
|
||||
- **Formatting**: Prettier with Tailwind plugin, run `pnpm fix:format`
|
||||
- **Linting**: ESLint with shared config, max warnings vary by package
|
||||
- **Formatting**: oxfmt, run `pnpm fix:format`
|
||||
- **Linting**: OxLint with shared `.oxlintrc.json` config
|
||||
- **Naming**: camelCase for variables/functions, PascalCase for components/types
|
||||
- **Error Handling**: Use try-catch with proper error types, log errors appropriately
|
||||
- **State Management**: MobX stores in `packages/shared-state`, reactive patterns
|
||||
|
||||
+8
-1
@@ -1 +1,8 @@
|
||||
eslint.config.mjs @lifeiscontent
|
||||
.oxlintrc.json @sriramveeraghanta @lifeiscontent
|
||||
.oxfmtrc.json @sriramveeraghanta @lifeiscontent
|
||||
apps/api/ @dheeru0198 @pablohashescobar
|
||||
apps/web/ @sriramveeraghanta
|
||||
apps/space/ @sriramveeraghanta
|
||||
apps/admin/ @sriramveeraghanta
|
||||
apps/live/ @Palanikannan1437
|
||||
deployments/ @mguptahub
|
||||
+2
-2
@@ -91,7 +91,7 @@ If you would like to _implement_ it, an issue with your proposal must be submitt
|
||||
To ensure consistency throughout the source code, please keep these rules in mind as you are working:
|
||||
|
||||
- All features or bug fixes must be tested by one or more specs (unit-tests).
|
||||
- We lint with [ESLint 9](https://eslint.org/docs/latest/) using the shared `eslint.config.mjs` (type-aware via `typescript-eslint`) and format with [Prettier](https://prettier.io/) using `prettier.config.cjs`.
|
||||
- We lint with [OxLint](https://oxc.rs/docs/guide/usage/linter) using the shared `.oxlintrc.json` and format with [oxfmt](https://oxc.rs/docs/guide/usage/formatter) using `.oxfmtrc.json`.
|
||||
|
||||
## Ways to contribute
|
||||
|
||||
@@ -244,4 +244,4 @@ Happy translating! 🌍✨
|
||||
|
||||
## Need help? Questions and suggestions
|
||||
|
||||
Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge).
|
||||
Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Forum](https://forum.plane.so).
|
||||
|
||||
@@ -7,17 +7,10 @@
|
||||
</p>
|
||||
<p align="center"><b>Modern project management for all teams</b></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.com/invite/A92xrEGCge">
|
||||
<img alt="Discord online members" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" />
|
||||
</a>
|
||||
<img alt="Commit activity per month" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://plane.so/"><b>Website</b></a> •
|
||||
<a href="https://github.com/makeplane/plane/releases"><b>Releases</b></a> •
|
||||
<a href="https://twitter.com/planepowers"><b>Twitter</b></a> •
|
||||
<a href="https://forum.plane.so"><b>Forum</b></a> •
|
||||
<a href="https://x.com/planepowers"><b>X</b></a> •
|
||||
<a href="https://docs.plane.so/"><b>Documentation</b></a>
|
||||
</p>
|
||||
|
||||
@@ -33,7 +26,7 @@
|
||||
|
||||
Meet [Plane](https://plane.so/), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘♀️
|
||||
|
||||
> Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Discord](https://discord.com/invite/A92xrEGCge) or raise a GitHub issue. We read everything and respond to most.
|
||||
> Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Forum](https://forum.plane.so) or raise a GitHub issue. We read everything and respond to most.
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
@@ -136,7 +129,7 @@ Explore Plane's [product documentation](https://docs.plane.so/) and [developer d
|
||||
|
||||
## ❤️ Community
|
||||
|
||||
Join the Plane community on [GitHub Discussions](https://github.com/orgs/makeplane/discussions) and our [Discord server](https://discord.com/invite/A92xrEGCge). We follow a [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) in all our community channels.
|
||||
Join the Plane community on [GitHub Discussions](https://github.com/orgs/makeplane/discussions) and our [Forum](https://forum.plane.so). We follow a [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) in all our community channels.
|
||||
|
||||
Feel free to ask questions, report bugs, participate in discussions, share ideas, request features, or showcase your projects. We’d love to hear from you!
|
||||
|
||||
@@ -152,7 +145,7 @@ There are many ways you can contribute to Plane:
|
||||
|
||||
- Report [bugs](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%F0%9F%90%9Bbug&projects=&template=--bug-report.yaml&title=%5Bbug%5D%3A+) or submit [feature requests](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%E2%9C%A8feature&projects=&template=--feature-request.yaml&title=%5Bfeature%5D%3A+).
|
||||
- Review the [documentation](https://docs.plane.so/) and submit [pull requests](https://github.com/makeplane/docs) to improve it—whether it's fixing typos or adding new content.
|
||||
- Talk or write about Plane or any other ecosystem integration and [let us know](https://discord.com/invite/A92xrEGCge)!
|
||||
- Talk or write about Plane or any other ecosystem integration and [let us know](https://forum.plane.so)!
|
||||
- Show your support by upvoting [popular feature requests](https://github.com/makeplane/plane/issues).
|
||||
|
||||
Please read [CONTRIBUTING.md](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md) for details on the process for submitting pull requests to us.
|
||||
|
||||
@@ -4,7 +4,7 @@ WORKDIR /app
|
||||
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
|
||||
ENV CI=1
|
||||
|
||||
RUN corepack enable pnpm
|
||||
@@ -13,7 +13,7 @@ RUN corepack enable pnpm
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
RUN pnpm add -g turbo@2.6.3
|
||||
RUN pnpm add -g turbo@2.9.4
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -120,16 +120,16 @@ export function InstanceAIForm(props: IInstanceAIForm) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 items-start">
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Saving" : "Save changes"}
|
||||
</Button>
|
||||
|
||||
<div className="relative inline-flex items-center gap-1.5 rounded-sm border border-accent-subtle bg-accent-subtle px-4 py-2 text-caption-sm-regular text-accent-secondary ">
|
||||
<div className="relative inline-flex items-center gap-1.5 rounded-sm border border-accent-subtle bg-accent-subtle px-4 py-2 text-caption-sm-regular text-accent-secondary">
|
||||
<Lightbulb className="size-4" />
|
||||
<div>
|
||||
If you have a preferred AI models vendor, please get in{" "}
|
||||
<a className="underline font-medium" href="https://plane.so/contact">
|
||||
<a className="font-medium underline" href="https://plane.so/contact">
|
||||
touch with us.
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentP
|
||||
) : (
|
||||
<Loader className="space-y-8">
|
||||
<Loader.Item height="50px" width="40%" />
|
||||
<div className="w-2/3 grid grid-cols-2 gap-x-8 gap-y-4">
|
||||
<div className="grid w-2/3 grid-cols-2 gap-x-8 gap-y-4">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
</div>
|
||||
|
||||
@@ -176,8 +176,8 @@ export function InstanceGiteaConfigForm(props: Props) {
|
||||
handleClose={() => setIsDiscardChangesModalOpen(false)}
|
||||
/>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
|
||||
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
|
||||
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
|
||||
<div className="pt-2.5 text-18 font-medium">Gitea-provided details for Plane</div>
|
||||
{GITEA_FORM_FIELDS.map((field) => (
|
||||
<ControllerInput
|
||||
@@ -211,7 +211,7 @@ export function InstanceGiteaConfigForm(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-layer-1 rounded-lg">
|
||||
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 pt-1.5 pb-4">
|
||||
<div className="pt-2 text-18 font-medium">Plane-provided details for Gitea</div>
|
||||
{GITEA_SERVICE_FIELD.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
|
||||
@@ -197,8 +197,8 @@ export function InstanceGithubConfigForm(props: Props) {
|
||||
handleClose={() => setIsDiscardChangesModalOpen(false)}
|
||||
/>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
|
||||
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
|
||||
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
|
||||
<div className="pt-2.5 text-18 font-medium">GitHub-provided details for Plane</div>
|
||||
{GITHUB_FORM_FIELDS.map((field) => (
|
||||
<ControllerInput
|
||||
@@ -231,24 +231,24 @@ export function InstanceGithubConfigForm(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
|
||||
<div className="col-span-2 flex flex-col gap-y-6 md:col-span-1">
|
||||
<div className="pt-2 text-18 font-medium">Plane-provided details for GitHub</div>
|
||||
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{/* common service details */}
|
||||
<div className="flex flex-col gap-y-4 px-6 py-4 bg-layer-1 rounded-lg">
|
||||
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 py-4">
|
||||
{GITHUB_COMMON_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* web service details */}
|
||||
<div className="flex flex-col rounded-lg overflow-hidden">
|
||||
<div className="px-6 py-3 bg-layer-3 font-medium text-11 uppercase flex items-center gap-x-3 text-secondary">
|
||||
<Monitor className="w-3 h-3" />
|
||||
<div className="flex flex-col overflow-hidden rounded-lg">
|
||||
<div className="flex items-center gap-x-3 bg-layer-3 px-6 py-3 text-11 font-medium text-secondary uppercase">
|
||||
<Monitor className="h-3 w-3" />
|
||||
Web
|
||||
</div>
|
||||
<div className="px-6 py-4 flex flex-col gap-y-4 bg-layer-1">
|
||||
<div className="flex flex-col gap-y-4 bg-layer-1 px-6 py-4">
|
||||
{GITHUB_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
|
||||
@@ -180,8 +180,8 @@ export function InstanceGitlabConfigForm(props: Props) {
|
||||
handleClose={() => setIsDiscardChangesModalOpen(false)}
|
||||
/>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
|
||||
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
|
||||
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
|
||||
<div className="pt-2.5 text-18 font-medium">GitLab-provided details for Plane</div>
|
||||
{GITLAB_FORM_FIELDS.map((field) => (
|
||||
<ControllerInput
|
||||
@@ -215,7 +215,7 @@ export function InstanceGitlabConfigForm(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-layer-3 rounded-lg">
|
||||
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-3 px-6 pt-1.5 pb-4">
|
||||
<div className="pt-2 text-18 font-medium">Plane-provided details for GitLab</div>
|
||||
{GITLAB_SERVICE_FIELD.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
|
||||
@@ -185,8 +185,8 @@ export function InstanceGoogleConfigForm(props: Props) {
|
||||
handleClose={() => setIsDiscardChangesModalOpen(false)}
|
||||
/>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
|
||||
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
|
||||
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
|
||||
<div className="pt-2.5 text-18 font-medium">Google-provided details for Plane</div>
|
||||
{GOOGLE_FORM_FIELDS.map((field) => (
|
||||
<ControllerInput
|
||||
@@ -219,24 +219,24 @@ export function InstanceGoogleConfigForm(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
|
||||
<div className="col-span-2 flex flex-col gap-y-6 md:col-span-1">
|
||||
<div className="pt-2 text-18 font-medium">Plane-provided details for Google</div>
|
||||
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{/* common service details */}
|
||||
<div className="flex flex-col gap-y-4 px-6 py-4 bg-layer-1 rounded-lg">
|
||||
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 py-4">
|
||||
{GOOGLE_COMMON_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* web service details */}
|
||||
<div className="flex flex-col rounded-lg overflow-hidden">
|
||||
<div className="px-6 py-3 bg-layer-3 font-medium text-11 uppercase flex items-center gap-x-3 text-secondary">
|
||||
<Monitor className="w-3 h-3" />
|
||||
<div className="flex flex-col overflow-hidden rounded-lg">
|
||||
<div className="flex items-center gap-x-3 bg-layer-3 px-6 py-3 text-11 font-medium text-secondary uppercase">
|
||||
<Monitor className="h-3 w-3" />
|
||||
Web
|
||||
</div>
|
||||
<div className="px-6 py-4 flex flex-col gap-y-4 bg-layer-1">
|
||||
<div className="flex flex-col gap-y-4 bg-layer-1 px-6 py-4">
|
||||
{GOOGLE_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
|
||||
@@ -117,11 +117,11 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
|
||||
>
|
||||
{formattedConfig ? (
|
||||
<div className="space-y-3">
|
||||
<div className={cn("w-full flex items-center gap-14 rounded-sm")}>
|
||||
<div className={cn("flex w-full items-center gap-14 rounded-sm")}>
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="grow">
|
||||
<div className="text-16 font-medium pb-1">Allow anyone to sign up even without an invite</div>
|
||||
<div className={cn("font-regular leading-5 text-tertiary text-11")}>
|
||||
<div className="pb-1 text-16 font-medium">Allow anyone to sign up even without an invite</div>
|
||||
<div className={cn("text-11 leading-5 font-regular text-tertiary")}>
|
||||
Toggling this off will only let users sign up when they are invited.
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +143,7 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg font-medium pt-6">Available authentication modes</div>
|
||||
<div className="text-lg pt-6 font-medium">Available authentication modes</div>
|
||||
{authenticationModes.map((method) => (
|
||||
<AuthenticationMethodCard
|
||||
key={method.key}
|
||||
|
||||
@@ -179,7 +179,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 my-6 pt-4 border-t border-subtle">
|
||||
<div className="my-6 flex flex-col gap-6 border-t border-subtle pt-4">
|
||||
<div className="flex w-full max-w-xl flex-col gap-y-10 px-1">
|
||||
<div className="mr-8 flex items-center gap-10 pt-4">
|
||||
<div className="grow">
|
||||
@@ -207,7 +207,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex max-w-4xl items-center py-1 gap-4">
|
||||
<div className="flex max-w-4xl items-center gap-4 py-1">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
|
||||
@@ -91,8 +91,8 @@ export function SendTestEmailModal(props: Props) {
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-surface-1 p-5 px-4 text-left shadow-raised-200 transition-all w-full sm:max-w-xl">
|
||||
<h3 className="text-16 font-medium leading-6 text-primary">
|
||||
<Dialog.Panel className="relative w-full transform rounded-lg bg-surface-1 p-5 px-4 text-left shadow-raised-200 transition-all sm:max-w-xl">
|
||||
<h3 className="text-16 leading-6 font-medium text-primary">
|
||||
{sendEmailStep === ESendEmailSteps.SEND_EMAIL
|
||||
? "Send test email"
|
||||
: sendEmailStep === ESendEmailSteps.SUCCESS
|
||||
@@ -121,7 +121,7 @@ export function SendTestEmailModal(props: Props) {
|
||||
</div>
|
||||
)}
|
||||
{sendEmailStep === ESendEmailSteps.FAILED && <div className="text-13">{error}</div>}
|
||||
<div className="flex items-center gap-2 justify-end mt-5">
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" size="lg" onClick={handleClose} tabIndex={2}>
|
||||
{sendEmailStep === ESendEmailSteps.SEND_EMAIL ? "Cancel" : "Close"}
|
||||
</Button>
|
||||
|
||||
@@ -16,8 +16,6 @@ import { Input, ToggleSwitch } from "@plane/ui";
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// components
|
||||
import { IntercomConfig } from "./intercom";
|
||||
|
||||
export interface IGeneralConfigurationForm {
|
||||
instance: IInstance;
|
||||
@@ -27,14 +25,13 @@ export interface IGeneralConfigurationForm {
|
||||
export const GeneralConfigurationForm = observer(function GeneralConfigurationForm(props: IGeneralConfigurationForm) {
|
||||
const { instance, instanceAdmins } = props;
|
||||
// hooks
|
||||
const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance();
|
||||
const { updateInstanceInfo } = useInstance();
|
||||
|
||||
// form data
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
watch,
|
||||
} = useForm<Partial<IInstance>>({
|
||||
defaultValues: {
|
||||
instance_name: instance?.instance_name,
|
||||
@@ -45,17 +42,6 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
|
||||
const onSubmit = async (formData: Partial<IInstance>) => {
|
||||
const payload: Partial<IInstance> = { ...formData };
|
||||
|
||||
// update the intercom configuration
|
||||
const isIntercomEnabled =
|
||||
instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1";
|
||||
if (!payload.is_telemetry_enabled && isIntercomEnabled) {
|
||||
try {
|
||||
await updateInstanceConfigurations({ IS_INTERCOM_ENABLED: "0" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
await updateInstanceInfo(payload)
|
||||
.then(() =>
|
||||
setToast({
|
||||
@@ -112,18 +98,17 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="text-16 font-medium text-primary pb-1.5 border-b border-subtle">Chat + telemetry</div>
|
||||
<IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
|
||||
<div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">Telemetry</div>
|
||||
<div className="flex items-center gap-14">
|
||||
<div className="grow flex items-center gap-4">
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center justify-center size-11 bg-layer-1 rounded-lg">
|
||||
<div className="flex size-11 items-center justify-center rounded-lg bg-layer-1">
|
||||
<Telescope className="size-5 text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="text-13 font-medium text-primary leading-5">Let Plane collect anonymous usage data</div>
|
||||
<div className="text-11 font-regular text-tertiary leading-5">
|
||||
<div className="text-13 leading-5 font-medium text-primary">Let Plane collect anonymous usage data</div>
|
||||
<div className="text-11 leading-5 font-regular text-tertiary">
|
||||
No PII is collected.This anonymized data is used to understand how you use Plane and build new features
|
||||
in line with{" "}
|
||||
<a
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import type { IFormattedInstanceConfiguration } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type TIntercomConfig = {
|
||||
isTelemetryEnabled: boolean;
|
||||
};
|
||||
|
||||
export const IntercomConfig = observer(function IntercomConfig(props: TIntercomConfig) {
|
||||
const { isTelemetryEnabled } = props;
|
||||
// hooks
|
||||
const { instanceConfigurations, updateInstanceConfigurations, fetchInstanceConfigurations } = useInstance();
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
|
||||
// derived values
|
||||
const isIntercomEnabled = isTelemetryEnabled
|
||||
? instanceConfigurations
|
||||
? instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1"
|
||||
? true
|
||||
: false
|
||||
: undefined
|
||||
: false;
|
||||
|
||||
const { isLoading } = useSWR(isTelemetryEnabled ? "INSTANCE_CONFIGURATIONS" : null, () =>
|
||||
isTelemetryEnabled ? fetchInstanceConfigurations() : null
|
||||
);
|
||||
|
||||
const initialLoader = isLoading && isIntercomEnabled === undefined;
|
||||
|
||||
const submitInstanceConfigurations = async (payload: Partial<IFormattedInstanceConfiguration>) => {
|
||||
try {
|
||||
await updateInstanceConfigurations(payload);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const enableIntercomConfig = () => {
|
||||
void submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-14">
|
||||
<div className="grow flex items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center justify-center size-11 bg-layer-1 rounded-lg">
|
||||
<MessageSquare className="size-5 text-tertiary p-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grow">
|
||||
<div className="text-13 font-medium text-primary leading-5">Chat with us</div>
|
||||
<div className="text-11 font-regular text-tertiary leading-5">
|
||||
Let your users chat with us via Intercom or another service. Toggling Telemetry off turns this off
|
||||
automatically.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto">
|
||||
<ToggleSwitch
|
||||
value={isIntercomEnabled ? true : false}
|
||||
onChange={enableIntercomConfig}
|
||||
size="sm"
|
||||
disabled={!isTelemetryEnabled || isSubmitting || initialLoader}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -42,7 +42,7 @@ function AdminLayout(_props: Route.ComponentProps) {
|
||||
<AdminSidebar />
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-surface-1">
|
||||
<AdminHeader />
|
||||
<div className="h-full w-full overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md">
|
||||
<div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-scroll">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -39,14 +39,14 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
|
||||
const getSidebarMenuItems = () => (
|
||||
<Menu.Items
|
||||
className={cn(
|
||||
"absolute left-0 z-20 mt-1.5 flex w-52 flex-col divide-y divide-subtle rounded-md border border-subtle bg-surface-1 px-1 py-2 text-11 shadow-lg outline-none",
|
||||
"shadow-lg absolute left-0 z-20 mt-1.5 flex w-52 flex-col divide-y divide-subtle rounded-md border border-subtle bg-surface-1 px-1 py-2 text-11 outline-none",
|
||||
{
|
||||
"left-4": isSidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 pb-2">
|
||||
<span className="px-2 text-secondary truncate">{currentUser?.email}</span>
|
||||
<span className="truncate px-2 text-secondary">{currentUser?.email}</span>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<Menu.Item
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { HelpCircle, MoveLeft } from "lucide-react";
|
||||
import { HelpCircle, MessageSquare, MoveLeft } from "lucide-react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import { WEB_BASE_URL } from "@plane/constants";
|
||||
// plane internal packages
|
||||
import { DiscordIcon, GithubIcon, NewTabIcon, PageIcon } from "@plane/propel/icons";
|
||||
import { GithubIcon, NewTabIcon, PageIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
@@ -25,9 +25,9 @@ const helpOptions = [
|
||||
Icon: PageIcon,
|
||||
},
|
||||
{
|
||||
name: "Join our Discord",
|
||||
href: "https://discord.com/invite/A92xrEGCge",
|
||||
Icon: DiscordIcon,
|
||||
name: "Join our Forum",
|
||||
href: "https://forum.plane.so",
|
||||
Icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
name: "Report a bug",
|
||||
@@ -50,9 +50,9 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1 self-baseline border-t border-subtle bg-surface-1 px-4 h-14 flex-shrink-0",
|
||||
"flex h-14 w-full flex-shrink-0 items-center justify-between gap-1 self-baseline border-t border-subtle bg-surface-1 px-4",
|
||||
{
|
||||
"flex-col h-auto py-1.5": isSidebarCollapsed,
|
||||
"h-auto flex-col py-1.5": isSidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
@@ -60,7 +60,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
|
||||
<Tooltip tooltipContent="Redirect to Plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
|
||||
<a
|
||||
href={redirectionLink}
|
||||
className={`relative px-2 py-1 flex items-center gap-1 rounded-sm bg-layer-1 text-body-xs-medium text-secondary whitespace-nowrap`}
|
||||
className={`relative flex items-center gap-1 rounded-sm bg-layer-1 px-2 py-1 text-body-xs-medium whitespace-nowrap text-secondary`}
|
||||
>
|
||||
<NewTabIcon width={14} height={14} />
|
||||
{!isSidebarCollapsed && "Redirect to Plane"}
|
||||
@@ -101,9 +101,9 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
className={`absolute bottom-2 min-w-[10rem] z-[15] ${
|
||||
className={`absolute bottom-2 z-[15] min-w-[10rem] ${
|
||||
isSidebarCollapsed ? "left-full" : "-left-[75px]"
|
||||
} divide-y divide-subtle-1 whitespace-nowrap rounded-sm bg-surface-1 p-1 shadow-raised-100`}
|
||||
} divide-y divide-subtle-1 rounded-sm bg-surface-1 p-1 whitespace-nowrap shadow-raised-100`}
|
||||
ref={helpOptionsRef}
|
||||
>
|
||||
<div className="space-y-1 pb-2">
|
||||
@@ -134,7 +134,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="px-2 pb-1 pt-2 text-10">Version: v{instance?.current_version}</div>
|
||||
<div className="px-2 pt-2 pb-1 text-10">Version: v{instance?.current_version}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-2.5 overflow-y-scroll vertical-scrollbar scrollbar-sm px-4 py-4">
|
||||
<div className="vertical-scrollbar flex scrollbar-sm h-full w-full flex-col gap-2.5 overflow-y-scroll px-4 py-4">
|
||||
{sidebarMenu.map((item, index) => {
|
||||
const isActive = item.href === pathName || pathName?.includes(item.href);
|
||||
return (
|
||||
@@ -38,9 +38,9 @@ export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
|
||||
<Tooltip tooltipContent={item.name} position="right" className="ml-2" disabled={!isSidebarCollapsed}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-3 rounded-md px-3 py-2 outline-none transition-colors",
|
||||
"group flex w-full items-center gap-3 rounded-md px-3 py-2 transition-colors outline-none",
|
||||
{
|
||||
"text-primary !bg-layer-transparent-active": isActive,
|
||||
"!bg-layer-transparent-active text-primary": isActive,
|
||||
"text-secondary hover:bg-layer-transparent-hover active:bg-layer-transparent-active": !isActive,
|
||||
},
|
||||
isSidebarCollapsed ? "justify-center" : "w-[260px]"
|
||||
@@ -48,7 +48,7 @@ export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
|
||||
>
|
||||
{<item.Icon className="h-4 w-4 flex-shrink-0" />}
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="w-full ">
|
||||
<div className="w-full">
|
||||
<div className={cn(`text-body-xs-medium transition-colors`)}>{item.name}</div>
|
||||
<div className={cn(`text-caption-sm-regular transition-colors`)}>{item.description}</div>
|
||||
</div>
|
||||
|
||||
@@ -44,13 +44,7 @@ export const AdminSidebar = observer(function AdminSidebar() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-subtle bg-surface-1 duration-300
|
||||
fixed md:relative
|
||||
${isSidebarCollapsed ? "-ml-[290px]" : ""}
|
||||
sm:${isSidebarCollapsed ? "-ml-[290px]" : ""}
|
||||
md:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"}
|
||||
lg:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"}
|
||||
`}
|
||||
className={`fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-subtle bg-surface-1 duration-300 md:relative ${isSidebarCollapsed ? "-ml-[290px]" : ""} sm:${isSidebarCollapsed ? "-ml-[290px]" : ""} md:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} lg:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} `}
|
||||
>
|
||||
<div ref={ref} className="flex h-full w-full flex-1 flex-col">
|
||||
<AdminSidebarDropdown />
|
||||
|
||||
@@ -123,8 +123,8 @@ export function WorkspaceCreateForm() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-13 text-tertiary">Set your workspace's URL</h4>
|
||||
<div className="flex gap-0.5 w-full items-center rounded-md border-[0.5px] border-subtle px-3">
|
||||
<span className="whitespace-nowrap text-13 text-secondary">{workspaceBaseURL}</span>
|
||||
<div className="flex w-full items-center gap-0.5 rounded-md border-[0.5px] border-subtle px-3">
|
||||
<span className="text-13 whitespace-nowrap text-secondary">{workspaceBaseURL}</span>
|
||||
<Controller
|
||||
control={control}
|
||||
name="slug"
|
||||
@@ -188,7 +188,7 @@ export function WorkspaceCreateForm() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex max-w-4xl items-center py-1 gap-4">
|
||||
<div className="flex max-w-4xl items-center gap-4 py-1">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
|
||||
@@ -83,11 +83,11 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{formattedConfig ? (
|
||||
<div className={cn("w-full flex items-center gap-14 rounded-sm")}>
|
||||
<div className={cn("flex w-full items-center gap-14 rounded-sm")}>
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="grow">
|
||||
<div className="text-16 font-medium pb-1">Prevent anyone else from creating a workspace.</div>
|
||||
<div className={cn("font-regular leading-5 text-tertiary text-11")}>
|
||||
<div className="pb-1 text-16 font-medium">Prevent anyone else from creating a workspace.</div>
|
||||
<div className={cn("text-11 leading-5 font-regular text-tertiary")}>
|
||||
Toggling this on will let only you create workspaces. You will have to invite users to new workspaces.
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,15 +116,15 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
||||
)}
|
||||
{workspaceLoader !== "init-loader" ? (
|
||||
<>
|
||||
<div className="pt-6 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center justify-between gap-2 pt-6">
|
||||
<div className="flex flex-col items-start gap-x-2">
|
||||
<div className="flex items-center gap-2 text-16 font-medium">
|
||||
All workspaces on this instance <span className="text-tertiary">• {workspaceIds.length}</span>
|
||||
{workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && (
|
||||
<LoaderIcon className="w-4 h-4 animate-spin" />
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("font-regular leading-5 text-tertiary text-11")}>
|
||||
<div className={cn("text-11 leading-5 font-regular text-tertiary")}>
|
||||
You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a
|
||||
Member.
|
||||
</div>
|
||||
@@ -149,7 +149,7 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
||||
disabled={workspaceLoader === "pagination"}
|
||||
>
|
||||
Load more
|
||||
{workspaceLoader === "pagination" && <LoaderIcon className="w-3 h-3 animate-spin" />}
|
||||
{workspaceLoader === "pagination" && <LoaderIcon className="h-3 w-3 animate-spin" />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -20,16 +20,16 @@ export function AuthBanner(props: TAuthBanner) {
|
||||
|
||||
if (!bannerData) return <></>;
|
||||
return (
|
||||
<div className="relative flex items-center p-2 rounded-md gap-2 border border-accent-strong/50 bg-accent-primary/10">
|
||||
<div className="w-4 h-4 flex-shrink-0 relative flex justify-center items-center">
|
||||
<div className="relative flex items-center gap-2 rounded-md border border-accent-strong/50 bg-accent-primary/10 p-2">
|
||||
<div className="relative flex h-4 w-4 flex-shrink-0 items-center justify-center">
|
||||
<Info size={16} className="text-accent-primary" />
|
||||
</div>
|
||||
<div className="w-full text-13 font-medium text-accent-primary">{bannerData?.message}</div>
|
||||
<div
|
||||
className="relative ml-auto w-6 h-6 rounded-xs flex justify-center items-center transition-all cursor-pointer hover:bg-accent-primary/20 text-accent-primary"
|
||||
className="relative ml-auto flex h-6 w-6 cursor-pointer items-center justify-center rounded-xs text-accent-primary transition-all hover:bg-accent-primary/20"
|
||||
onClick={() => handleBannerData && handleBannerData(undefined)}
|
||||
>
|
||||
<CloseIcon className="w-4 h-4 flex-shrink-0" />
|
||||
<CloseIcon className="h-4 w-4 flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { PlaneLockup } from "@plane/propel/icons";
|
||||
|
||||
export function AuthHeader() {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
|
||||
<div className="sticky top-0 flex w-full flex-shrink-0 items-center justify-between gap-6">
|
||||
<Link href="/">
|
||||
<PlaneLockup height={20} width={95} className="text-primary" />
|
||||
</Link>
|
||||
|
||||
@@ -50,7 +50,7 @@ const errorCodeMessages: {
|
||||
message: () => (
|
||||
<div>
|
||||
Admin user already exists.
|
||||
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
|
||||
<Link className="font-medium underline underline-offset-4 transition-all hover:font-bold" href={`/admin`}>
|
||||
Sign In
|
||||
</Link>
|
||||
now.
|
||||
@@ -62,7 +62,7 @@ const errorCodeMessages: {
|
||||
message: () => (
|
||||
<div>
|
||||
Admin user does not exist.
|
||||
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
|
||||
<Link className="font-medium underline underline-offset-4 transition-all hover:font-bold" href={`/admin`}>
|
||||
Sign In
|
||||
</Link>
|
||||
now.
|
||||
|
||||
@@ -22,7 +22,7 @@ function RootLayout() {
|
||||
}, [replace, isUserLoggedIn]);
|
||||
|
||||
return (
|
||||
<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 bg-surface-1">
|
||||
<div className="relative z-10 flex h-screen w-screen flex-col items-center overflow-hidden overflow-y-auto bg-surface-1 px-8 pt-6 pb-10">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ function HomePage() {
|
||||
// if instance is not fetched, show loading
|
||||
if (!instance && !error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen w-full">
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -111,8 +111,8 @@ export function InstanceSignInForm() {
|
||||
return (
|
||||
<>
|
||||
<AuthHeader />
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
|
||||
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6">
|
||||
<FormHeader
|
||||
heading="Manage your Plane instance"
|
||||
subHeading="Configure instance-wide settings to secure your instance"
|
||||
@@ -134,7 +134,7 @@ export function InstanceSignInForm() {
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-13 text-tertiary font-medium" htmlFor="email">
|
||||
<label className="text-13 font-medium text-tertiary" htmlFor="email">
|
||||
Email <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<Input
|
||||
@@ -152,7 +152,7 @@ export function InstanceSignInForm() {
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-13 text-tertiary font-medium" htmlFor="password">
|
||||
<label className="text-13 font-medium text-tertiary" htmlFor="password">
|
||||
Password <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -170,7 +170,7 @@ export function InstanceSignInForm() {
|
||||
{showPassword ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-placeholder"
|
||||
className="absolute top-3.5 right-3 flex items-center justify-center text-placeholder"
|
||||
onClick={() => setShowPassword(false)}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
@@ -178,7 +178,7 @@ export function InstanceSignInForm() {
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-placeholder"
|
||||
className="absolute top-3.5 right-3 flex items-center justify-center text-placeholder"
|
||||
onClick={() => setShowPassword(true)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
|
||||
@@ -4,31 +4,10 @@
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as Sentry from "@sentry/react-router";
|
||||
import { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { HydratedRouter } from "react-router/dom";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.VITE_SENTRY_DSN,
|
||||
environment: process.env.VITE_SENTRY_ENVIRONMENT,
|
||||
sendDefaultPii: process.env.VITE_SENTRY_SEND_DEFAULT_PII ? process.env.VITE_SENTRY_SEND_DEFAULT_PII === "1" : false,
|
||||
release: process.env.VITE_APP_VERSION,
|
||||
tracesSampleRate: process.env.VITE_SENTRY_TRACES_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_TRACES_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
profilesSampleRate: process.env.VITE_SENTRY_PROFILES_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_PROFILES_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
replaysSessionSampleRate: process.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
replaysOnErrorSampleRate: process.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE)
|
||||
: 1.0,
|
||||
integrations: [],
|
||||
});
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Links, Meta, Outlet, Scripts } from "react-router";
|
||||
import type { LinksFunction } from "react-router";
|
||||
import * as Sentry from "@sentry/react-router";
|
||||
import appleTouchIcon from "@/app/assets/favicon/apple-touch-icon.png?url";
|
||||
import favicon16 from "@/app/assets/favicon/favicon-16x16.png?url";
|
||||
import favicon32 from "@/app/assets/favicon/favicon-32x32.png?url";
|
||||
@@ -75,7 +74,7 @@ export const meta: Route.MetaFunction = () => [
|
||||
|
||||
export default function Root() {
|
||||
return (
|
||||
<div className="bg-canvas min-h-screen">
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
@@ -89,11 +88,7 @@ export function HydrateFallback() {
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
if (error) {
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error: _error }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<div>
|
||||
<p>Something went wrong.</p>
|
||||
|
||||
@@ -22,8 +22,8 @@ export function AuthenticationMethodCard(props: Props) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-full flex items-center gap-14 rounded-lg bg-layer-2", {
|
||||
"px-4 py-3 border border-subtle": withBorder,
|
||||
className={cn("flex w-full items-center gap-14 rounded-lg bg-layer-2", {
|
||||
"border border-subtle px-4 py-3": withBorder,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
@@ -36,7 +36,7 @@ export function AuthenticationMethodCard(props: Props) {
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div
|
||||
className={cn("font-medium leading-5 text-primary", {
|
||||
className={cn("leading-5 font-medium text-primary", {
|
||||
"text-13": withBorder,
|
||||
"text-18": !withBorder,
|
||||
})}
|
||||
@@ -44,7 +44,7 @@ export function AuthenticationMethodCard(props: Props) {
|
||||
{name}
|
||||
</div>
|
||||
<div
|
||||
className={cn("font-regular leading-5 text-tertiary", {
|
||||
className={cn("leading-5 font-regular text-tertiary", {
|
||||
"text-11": withBorder,
|
||||
"text-13": !withBorder,
|
||||
})}
|
||||
|
||||
@@ -16,12 +16,12 @@ export function Banner(props: TBanner) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md p-2 w-full border ${type === "error" ? "bg-danger-subtle border-danger-strong" : "bg-success-subtle border-success-strong"}`}
|
||||
className={`w-full rounded-md border p-2 ${type === "error" ? "border-danger-strong bg-danger-subtle" : "border-success-strong bg-success-subtle"}`}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex-shrink-0">
|
||||
{type === "error" ? (
|
||||
<span className="flex items-center justify-center h-6 w-6 rounded-full">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full">
|
||||
<AlertCircle className="h-5 w-5 text-danger-primary" aria-hidden="true" />
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -22,12 +22,12 @@ export function BreadcrumbLink(props: Props) {
|
||||
{href ? (
|
||||
<Link className="flex items-center gap-1 text-13 font-medium text-tertiary hover:text-primary" href={href}>
|
||||
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden !text-16">{icon}</div>}
|
||||
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
|
||||
<div className="relative line-clamp-1 block max-w-[150px] truncate overflow-hidden">{label}</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex cursor-default items-center gap-1 text-13 font-medium text-primary">
|
||||
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden">{icon}</div>}
|
||||
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
|
||||
<div className="relative line-clamp-1 block max-w-[150px] truncate overflow-hidden">{label}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -16,9 +16,9 @@ export function CodeBlock({ children, className, darkerShade }: TProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"px-0.5 text-11 text-tertiary bg-surface-2 font-semibold rounded-md border border-subtle",
|
||||
"rounded-md border border-subtle bg-surface-2 px-0.5 text-11 font-semibold text-tertiary",
|
||||
{
|
||||
"text-secondary bg-layer-1 border-subtle": darkerShade,
|
||||
"border-subtle bg-layer-1 text-secondary": darkerShade,
|
||||
},
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -46,10 +46,10 @@ export function ConfirmDiscardModal(props: Props) {
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-surface-1 text-left shadow-raised-200 transition-all sm:my-8 sm:w-[30rem]">
|
||||
<div className="px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<Dialog.Title as="h3" className="text-16 font-medium leading-6 text-tertiary">
|
||||
<Dialog.Title as="h3" className="text-16 leading-6 font-medium text-tertiary">
|
||||
You have unsaved changes
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
@@ -60,7 +60,7 @@ export function ConfirmDiscardModal(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end items-center p-4 sm:px-6 gap-2">
|
||||
<div className="flex items-center justify-end gap-2 p-4 sm:px-6">
|
||||
<Button variant="secondary" size="lg" onClick={handleClose}>
|
||||
Keep editing
|
||||
</Button>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ControllerInput(props: Props) {
|
||||
(showPassword ? (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-2.5 flex items-center justify-center text-placeholder"
|
||||
className="absolute top-2.5 right-3 flex items-center justify-center text-placeholder"
|
||||
onClick={() => setShowPassword(false)}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
@@ -75,7 +75,7 @@ export function ControllerInput(props: Props) {
|
||||
) : (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-2.5 flex items-center justify-center text-placeholder"
|
||||
className="absolute top-2.5 right-3 flex items-center justify-center text-placeholder"
|
||||
onClick={() => setShowPassword(true)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
|
||||
@@ -25,7 +25,7 @@ export function EmptyState({ title, description, image, primaryButton, secondary
|
||||
<div className={`flex h-full w-full items-center justify-center`}>
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
{image && <img src={image} className="w-52 sm:w-60" alt={primaryButton?.text || "button image"} />}
|
||||
<h6 className="mb-3 mt-6 text-18 font-semibold sm:mt-8">{title}</h6>
|
||||
<h6 className="mt-6 mb-3 text-18 font-semibold sm:mt-8">{title}</h6>
|
||||
{description && <p className="mb-7 px-5 text-tertiary sm:mb-8">{description}</p>}
|
||||
<div className="flex items-center gap-4">
|
||||
{primaryButton && (
|
||||
|
||||
@@ -21,10 +21,10 @@ export const HamburgerToggle = observer(function HamburgerToggle() {
|
||||
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||
return (
|
||||
<button
|
||||
className="size-7 rounded-sm flex justify-center items-center bg-layer-1 transition-all hover:bg-layer-1-hover cursor-pointer group md:hidden"
|
||||
className="group flex size-7 cursor-pointer items-center justify-center rounded-sm bg-layer-1 transition-all hover:bg-layer-1-hover md:hidden"
|
||||
onClick={() => toggleSidebar(!isSidebarCollapsed)}
|
||||
>
|
||||
<Menu size={14} className="text-secondary group-hover:text-primary transition-all" />
|
||||
<Menu size={14} className="text-secondary transition-all group-hover:text-primary" />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export const NewUserPopup = observer(function NewUserPopup() {
|
||||
|
||||
if (!isNewUserPopup) return <></>;
|
||||
return (
|
||||
<div className="absolute bottom-8 right-8 p-6 w-96 border border-subtle shadow-md rounded-lg bg-surface-1">
|
||||
<div className="shadow-md absolute right-8 bottom-8 w-96 rounded-lg border border-subtle bg-surface-1 p-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="grow">
|
||||
<div className="text-14 font-semibold">Create workspace</div>
|
||||
@@ -41,7 +41,7 @@ export const NewUserPopup = observer(function NewUserPopup() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-center">
|
||||
<div className="flex shrink-0 items-center justify-center">
|
||||
<img
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? TakeoffIconDark : TakeoffIconLight}
|
||||
height={80}
|
||||
|
||||
@@ -24,25 +24,25 @@ export const PageWrapper = (props: TPageWrapperProps) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("mx-auto w-full h-full space-y-6 py-4", {
|
||||
"md:px-4 max-w-[1000px] 2xl:max-w-[1200px]": size === "md",
|
||||
className={cn("mx-auto h-full w-full space-y-6 py-4", {
|
||||
"max-w-[1000px] md:px-4 2xl:max-w-[1200px]": size === "md",
|
||||
"px-4 lg:px-12": size === "lg",
|
||||
})}
|
||||
>
|
||||
{customHeader ? (
|
||||
<div className="border-b border-subtle mx-4 py-4 space-y-1 shrink-0">{customHeader}</div>
|
||||
<div className="mx-4 shrink-0 space-y-1 border-b border-subtle py-4">{customHeader}</div>
|
||||
) : (
|
||||
header && (
|
||||
<div className="flex items-center justify-between gap-4 border-b border-subtle mx-4 py-4 space-y-1 shrink-0">
|
||||
<div className="mx-4 flex shrink-0 items-center justify-between gap-4 space-y-1 border-b border-subtle py-4">
|
||||
<div className={header.actions ? "flex flex-col gap-1" : "space-y-1"}>
|
||||
<div className="text-primary text-h5-semibold">{header.title}</div>
|
||||
<div className="text-secondary text-body-sm-regular">{header.description}</div>
|
||||
<div className="text-h5-semibold text-primary">{header.title}</div>
|
||||
<div className="text-body-sm-regular text-secondary">{header.description}</div>
|
||||
</div>
|
||||
{header.actions && <div className="shrink-0">{header.actions}</div>}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-sm px-4 pb-4">
|
||||
<div className="vertical-scrollbar scrollbar-sm flex-grow overflow-hidden overflow-y-scroll px-4 pb-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,12 +24,12 @@ export const InstanceFailureView = observer(function InstanceFailureView() {
|
||||
return (
|
||||
<>
|
||||
<AuthHeader />
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
<div className="relative flex flex-col justify-center items-center space-y-4">
|
||||
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
|
||||
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6">
|
||||
<div className="relative flex flex-col items-center justify-center space-y-4">
|
||||
<img src={instanceImage} alt="Instance failure illustration" />
|
||||
<h3 className="font-medium text-20 text-on-color text-center">Unable to fetch instance details.</h3>
|
||||
<p className="font-medium text-14 text-center">
|
||||
<h3 className="text-center text-20 font-medium text-on-color">Unable to fetch instance details.</h3>
|
||||
<p className="text-center text-14 font-medium">
|
||||
We were unable to fetch the details of the instance. Fret not, it might just be a connectivity issue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
export function FormHeader({ heading, subHeading }: { heading: string; subHeading: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-20 font-semibold text-primary leading-7">{heading}</span>
|
||||
<span className="text-16 font-semibold text-placeholder leading-7">{subHeading}</span>
|
||||
<span className="text-20 leading-7 font-semibold text-primary">{heading}</span>
|
||||
<span className="text-16 leading-7 font-semibold text-placeholder">{subHeading}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ import PlaneTakeOffImage from "@/app/assets/images/plane-takeoff.png?url";
|
||||
|
||||
export function InstanceNotReady() {
|
||||
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">
|
||||
<div className="relative flex flex-col justify-center items-center space-y-4">
|
||||
<h1 className="text-24 font-bold pb-3">Welcome aboard Plane!</h1>
|
||||
<div className="relative container mx-auto flex h-full w-full items-center justify-center px-5">
|
||||
<div className="relative w-auto max-w-2xl space-y-8 py-10">
|
||||
<div className="relative flex flex-col items-center justify-center space-y-4">
|
||||
<h1 className="pb-3 text-24 font-bold">Welcome aboard Plane!</h1>
|
||||
<img src={PlaneTakeOffImage} alt="Plane Logo" />
|
||||
<p className="font-medium text-14 text-placeholder">Get started by setting up your instance and workspace</p>
|
||||
<p className="text-14 font-medium text-placeholder">Get started by setting up your instance and workspace</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -139,8 +139,8 @@ export function InstanceSetupForm() {
|
||||
return (
|
||||
<>
|
||||
<AuthHeader />
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
|
||||
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6">
|
||||
<FormHeader
|
||||
heading="Setup your Plane Instance"
|
||||
subHeading="Post setup you will be able to manage this Plane instance."
|
||||
@@ -160,9 +160,9 @@ export function InstanceSetupForm() {
|
||||
<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="flex flex-col items-center gap-4 sm:flex-row">
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-13 text-tertiary font-medium" htmlFor="first_name">
|
||||
<label className="text-13 font-medium text-tertiary" htmlFor="first_name">
|
||||
First name <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<Input
|
||||
@@ -185,7 +185,7 @@ export function InstanceSetupForm() {
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-13 text-tertiary font-medium" htmlFor="last_name">
|
||||
<label className="text-13 font-medium text-tertiary" htmlFor="last_name">
|
||||
Last name <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<Input
|
||||
@@ -209,7 +209,7 @@ export function InstanceSetupForm() {
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-13 text-tertiary font-medium" htmlFor="email">
|
||||
<label className="text-13 font-medium text-tertiary" htmlFor="email">
|
||||
Email <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<Input
|
||||
@@ -230,7 +230,7 @@ export function InstanceSetupForm() {
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-13 text-tertiary font-medium" htmlFor="company_name">
|
||||
<label className="text-13 font-medium text-tertiary" htmlFor="company_name">
|
||||
Company name <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<Input
|
||||
@@ -252,7 +252,7 @@ export function InstanceSetupForm() {
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-13 text-tertiary font-medium" htmlFor="password">
|
||||
<label className="text-13 font-medium text-tertiary" htmlFor="password">
|
||||
Set a password <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -274,7 +274,7 @@ export function InstanceSetupForm() {
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-placeholder"
|
||||
className="absolute top-3.5 right-3 flex items-center justify-center text-placeholder"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
@@ -283,7 +283,7 @@ export function InstanceSetupForm() {
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-placeholder"
|
||||
className="absolute top-3.5 right-3 flex items-center justify-center text-placeholder"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
@@ -297,7 +297,7 @@ export function InstanceSetupForm() {
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-13 text-tertiary font-medium" htmlFor="confirm_password">
|
||||
<label className="text-13 font-medium text-tertiary" htmlFor="confirm_password">
|
||||
Confirm password <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -318,7 +318,7 @@ export function InstanceSetupForm() {
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-placeholder"
|
||||
className="absolute top-3.5 right-3 flex items-center justify-center text-placeholder"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
@@ -327,7 +327,7 @@ export function InstanceSetupForm() {
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-placeholder"
|
||||
className="absolute top-3.5 right-3 flex items-center justify-center text-placeholder"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
@@ -344,21 +344,21 @@ export function InstanceSetupForm() {
|
||||
<div className="relative flex gap-2">
|
||||
<div>
|
||||
<Checkbox
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-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-13 text-tertiary font-medium cursor-pointer" htmlFor="is_telemetry_enabled">
|
||||
<label className="cursor-pointer text-13 font-medium text-tertiary" 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-13 font-medium text-blue-500 hover:text-blue-600 flex-shrink-0"
|
||||
className="text-blue-500 hover:text-blue-600 flex-shrink-0 text-13 font-medium"
|
||||
>
|
||||
See More
|
||||
</a>
|
||||
|
||||
@@ -30,19 +30,19 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
||||
key={workspaceId}
|
||||
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
|
||||
target="_blank"
|
||||
className="group flex items-center justify-between p-3 gap-2.5 truncate border border-subtle hover:border-subtle-1 bg-layer-1 hover:bg-layer-1-hover hover:shadow-raised-100 rounded-lg"
|
||||
className="group flex items-center justify-between gap-2.5 truncate rounded-lg border border-subtle bg-layer-1 p-3 hover:border-subtle-1 hover:bg-layer-1-hover hover:shadow-raised-100"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<span
|
||||
className={`relative flex h-8 w-8 flex-shrink-0 items-center justify-center p-2 mt-1 text-11 uppercase ${
|
||||
className={`relative mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center p-2 text-11 uppercase ${
|
||||
!workspace?.logo_url && "rounded-lg bg-accent-primary text-on-color"
|
||||
}`}
|
||||
>
|
||||
{workspace?.logo_url && workspace.logo_url !== "" ? (
|
||||
<img
|
||||
src={getFileURL(workspace.logo_url)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-sm object-cover"
|
||||
className="absolute top-0 left-0 h-full w-full rounded-sm object-cover"
|
||||
alt="Workspace Logo"
|
||||
/>
|
||||
) : (
|
||||
@@ -50,7 +50,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
||||
)}
|
||||
</span>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex flex-wrap w-full items-center gap-2.5">
|
||||
<div className="flex w-full flex-wrap items-center gap-2.5">
|
||||
<h3 className={`text-14 font-medium capitalize`}>{workspace.name}</h3>/
|
||||
<Tooltip tooltipContent="The unique URL of your workspace">
|
||||
<h4 className="text-13 text-tertiary">[{workspace.slug}]</h4>
|
||||
@@ -58,14 +58,14 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
||||
</div>
|
||||
{workspace.owner.email && (
|
||||
<div className="flex items-center gap-1 text-11">
|
||||
<h3 className="text-secondary font-medium">Owned by:</h3>
|
||||
<h3 className="font-medium text-secondary">Owned by:</h3>
|
||||
<h4 className="text-tertiary">{workspace.owner.email}</h4>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2.5 text-11">
|
||||
{workspace.total_projects !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<h3 className="text-secondary font-medium">Total projects:</h3>
|
||||
<h3 className="font-medium text-secondary">Total projects:</h3>
|
||||
<h4 className="text-tertiary">{workspace.total_projects}</h4>
|
||||
</span>
|
||||
)}
|
||||
@@ -73,7 +73,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
||||
<>
|
||||
•
|
||||
<span className="flex items-center gap-1">
|
||||
<h3 className="text-secondary font-medium">Total members:</h3>
|
||||
<h3 className="font-medium text-secondary">Total members:</h3>
|
||||
<h4 className="text-tertiary">{workspace.total_members}</h4>
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Admin UI for Plane",
|
||||
"version": "1.2.0",
|
||||
"license": "AGPL-3.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"description": "Admin UI for Plane",
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "react-router dev --port 3001",
|
||||
@@ -11,11 +11,11 @@
|
||||
"preview": "react-router build && serve -s build/client -l 3001",
|
||||
"start": "serve -s build/client -l 3001",
|
||||
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist && rm -rf build",
|
||||
"check:lint": "eslint . --cache --cache-location node_modules/.cache/eslint/ --max-warnings=485",
|
||||
"check:lint": "oxlint --max-warnings=759 .",
|
||||
"check:types": "react-router typegen && tsc --noEmit",
|
||||
"check:format": "prettier . --cache --check",
|
||||
"fix:lint": "eslint . --cache --cache-location node_modules/.cache/eslint/ --fix --max-warnings=485",
|
||||
"fix:format": "prettier . --cache --write"
|
||||
"check:format": "oxfmt --check .",
|
||||
"fix:lint": "oxlint --fix .",
|
||||
"fix:format": "oxfmt ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@bprogress/core": "catalog:",
|
||||
@@ -31,7 +31,6 @@
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@react-router/node": "catalog:",
|
||||
"@sentry/react-router": "catalog:",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tanstack/virtual-core": "^3.13.12",
|
||||
"axios": "catalog:",
|
||||
@@ -50,7 +49,6 @@
|
||||
"uuid": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "catalog:",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@react-router/dev": "catalog:",
|
||||
@@ -58,6 +56,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from "node:path";
|
||||
import * as dotenv from "@dotenvx/dotenvx";
|
||||
import * as dotenv from "dotenv";
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "1.2.0",
|
||||
"license": "AGPL-3.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"description": "API server powering Plane's backend"
|
||||
"description": "API server powering Plane's backend",
|
||||
"license": "AGPL-3.0"
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ from .issue import (
|
||||
IssueCommentCreateSerializer,
|
||||
IssueLinkCreateSerializer,
|
||||
IssueLinkUpdateSerializer,
|
||||
IssueRelationCreateSerializer,
|
||||
IssueRelationResponseSerializer,
|
||||
IssueRelationSerializer,
|
||||
RelatedIssueSerializer,
|
||||
)
|
||||
from .state import StateLiteSerializer, StateSerializer
|
||||
from .cycle import (
|
||||
@@ -49,7 +53,7 @@ from .intake import (
|
||||
IntakeIssueCreateSerializer,
|
||||
IntakeIssueUpdateSerializer,
|
||||
)
|
||||
from .estimate import EstimatePointSerializer
|
||||
from .estimate import EstimateSerializer, EstimatePointSerializer
|
||||
from .asset import (
|
||||
UserAssetUploadSerializer,
|
||||
AssetUpdateSerializer,
|
||||
|
||||
@@ -2,20 +2,36 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import EstimatePoint
|
||||
from plane.db.models import Estimate, EstimatePoint
|
||||
from .base import BaseSerializer
|
||||
|
||||
|
||||
class EstimatePointSerializer(BaseSerializer):
|
||||
"""
|
||||
Serializer for project estimation points and story point values.
|
||||
class EstimateSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Estimate
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "project", "deleted_at"]
|
||||
|
||||
Handles numeric estimation data for work item sizing and sprint planning,
|
||||
providing standardized point values for project velocity calculations.
|
||||
"""
|
||||
def create(self, validated_data):
|
||||
validated_data["workspace"] = self.context["workspace"]
|
||||
validated_data["project"] = self.context["project"]
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class EstimatePointSerializer(BaseSerializer):
|
||||
def validate(self, data):
|
||||
if not data:
|
||||
raise serializers.ValidationError("Estimate points are required")
|
||||
value = data.get("value")
|
||||
if value and len(value) > 20:
|
||||
raise serializers.ValidationError("Value can't be more than 20 characters")
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
model = EstimatePoint
|
||||
fields = ["id", "value"]
|
||||
read_only_fields = fields
|
||||
fields = "__all__"
|
||||
read_only_fields = ["estimate", "workspace", "project"]
|
||||
|
||||
@@ -20,6 +20,7 @@ from plane.db.models import (
|
||||
IssueComment,
|
||||
IssueLabel,
|
||||
IssueLink,
|
||||
IssueRelation,
|
||||
Label,
|
||||
ProjectMember,
|
||||
State,
|
||||
@@ -479,6 +480,192 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
class IssueRelationRefSerializer(serializers.Serializer):
|
||||
"""Project-scoped reference to a related work item."""
|
||||
|
||||
project_id = serializers.UUIDField(help_text="Project containing the related work item")
|
||||
issue_id = serializers.UUIDField(help_text="ID of the related work item")
|
||||
|
||||
|
||||
class IssueRelationResponseSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for issue relations response showing grouped relation types.
|
||||
|
||||
Each list contains project_id and issue_id pairs so clients can resolve
|
||||
cross-project relations.
|
||||
"""
|
||||
|
||||
blocking = serializers.ListField(
|
||||
child=IssueRelationRefSerializer(),
|
||||
help_text="Work items blocking this issue",
|
||||
)
|
||||
blocked_by = serializers.ListField(
|
||||
child=IssueRelationRefSerializer(),
|
||||
help_text="Work items this issue is blocked by",
|
||||
)
|
||||
duplicate = serializers.ListField(
|
||||
child=IssueRelationRefSerializer(),
|
||||
help_text="Duplicate work items",
|
||||
)
|
||||
relates_to = serializers.ListField(
|
||||
child=IssueRelationRefSerializer(),
|
||||
help_text="Related work items",
|
||||
)
|
||||
start_after = serializers.ListField(
|
||||
child=IssueRelationRefSerializer(),
|
||||
help_text="Work items that start after this issue",
|
||||
)
|
||||
start_before = serializers.ListField(
|
||||
child=IssueRelationRefSerializer(),
|
||||
help_text="Work items that start before this issue",
|
||||
)
|
||||
finish_after = serializers.ListField(
|
||||
child=IssueRelationRefSerializer(),
|
||||
help_text="Work items that finish after this issue",
|
||||
)
|
||||
finish_before = serializers.ListField(
|
||||
child=IssueRelationRefSerializer(),
|
||||
help_text="Work items that finish before this issue",
|
||||
)
|
||||
|
||||
|
||||
class IssueRelationCreateSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for creating issue relations.
|
||||
|
||||
Creates issue relations with the specified relation type and issues.
|
||||
Validates relation types and ensures proper issue ID format.
|
||||
"""
|
||||
|
||||
RELATION_TYPE_CHOICES = [
|
||||
("blocking", "Blocking"),
|
||||
("blocked_by", "Blocked By"),
|
||||
("duplicate", "Duplicate"),
|
||||
("relates_to", "Relates To"),
|
||||
("start_before", "Start Before"),
|
||||
("start_after", "Start After"),
|
||||
("finish_before", "Finish Before"),
|
||||
("finish_after", "Finish After"),
|
||||
]
|
||||
|
||||
relation_type = serializers.ChoiceField(
|
||||
choices=RELATION_TYPE_CHOICES,
|
||||
required=True,
|
||||
help_text="Type of relationship between work items",
|
||||
)
|
||||
issues = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
required=True,
|
||||
min_length=1,
|
||||
help_text="Array of work item IDs to create relations with",
|
||||
)
|
||||
|
||||
def validate_issues(self, value):
|
||||
"""Validate that issues list is not empty and contains valid UUIDs."""
|
||||
if not value:
|
||||
raise serializers.ValidationError("At least one issue ID is required.")
|
||||
return value
|
||||
|
||||
|
||||
class IssueRelationRemoveSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for removing issue relations.
|
||||
|
||||
Removes existing relationships between work items by specifying
|
||||
the related issue ID.
|
||||
"""
|
||||
|
||||
related_issue = serializers.UUIDField(
|
||||
required=True, help_text="ID of the related work item to remove relation with"
|
||||
)
|
||||
|
||||
|
||||
class IssueRelationSerializer(BaseSerializer):
|
||||
"""
|
||||
Serializer for issue relationships showing related issue details.
|
||||
|
||||
Provides comprehensive information about related issues including
|
||||
project context, sequence ID, and relationship type.
|
||||
"""
|
||||
|
||||
id = serializers.UUIDField(source="related_issue.id", read_only=True)
|
||||
project_id = serializers.UUIDField(source="related_issue.project_id", read_only=True)
|
||||
sequence_id = serializers.IntegerField(source="related_issue.sequence_id", read_only=True)
|
||||
name = serializers.CharField(source="related_issue.name", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True)
|
||||
priority = serializers.CharField(source="related_issue.priority", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueRelation
|
||||
fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
"name",
|
||||
"state_id",
|
||||
"priority",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class RelatedIssueSerializer(BaseSerializer):
|
||||
"""
|
||||
Serializer for reverse issue relationships showing issue details.
|
||||
|
||||
Provides comprehensive information about the source issue in a relationship
|
||||
including project context, sequence ID, and relationship type.
|
||||
"""
|
||||
|
||||
id = serializers.UUIDField(source="issue.id", read_only=True)
|
||||
project_id = serializers.PrimaryKeyRelatedField(source="issue.project_id", read_only=True)
|
||||
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
|
||||
name = serializers.CharField(source="issue.name", read_only=True)
|
||||
type_id = serializers.UUIDField(source="issue.type.id", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
is_epic = serializers.BooleanField(source="issue.type.is_epic", read_only=True)
|
||||
state_id = serializers.UUIDField(source="issue.state.id", read_only=True)
|
||||
priority = serializers.CharField(source="issue.priority", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueRelation
|
||||
fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
"name",
|
||||
"type_id",
|
||||
"is_epic",
|
||||
"state_id",
|
||||
"priority",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class IssueAttachmentSerializer(BaseSerializer):
|
||||
"""
|
||||
Serializer for work item file attachments.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views.estimate import (
|
||||
ProjectEstimateAPIEndpoint,
|
||||
EstimatePointListCreateAPIEndpoint,
|
||||
EstimatePointDetailAPIEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/",
|
||||
ProjectEstimateAPIEndpoint.as_view(http_method_names=["get", "post", "patch", "delete"]),
|
||||
name="project-estimate",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/",
|
||||
EstimatePointListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="estimate-point-list-create",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/<uuid:estimate_point_id>/",
|
||||
EstimatePointDetailAPIEndpoint.as_view(http_method_names=["patch", "delete"]),
|
||||
name="estimate-point-detail",
|
||||
),
|
||||
]
|
||||
@@ -8,6 +8,7 @@ from plane.api.views import (
|
||||
ProjectListCreateAPIEndpoint,
|
||||
ProjectDetailAPIEndpoint,
|
||||
ProjectArchiveUnarchiveAPIEndpoint,
|
||||
ProjectSummaryAPIEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -26,4 +27,9 @@ urlpatterns = [
|
||||
ProjectArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]),
|
||||
name="project-archive-unarchive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/summary/",
|
||||
ProjectSummaryAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="project-summary",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -17,6 +17,7 @@ from plane.api.views import (
|
||||
IssueAttachmentDetailAPIEndpoint,
|
||||
WorkspaceIssueAPIEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
IssueRelationListCreateAPIEndpoint,
|
||||
)
|
||||
|
||||
# Deprecated url patterns
|
||||
@@ -145,6 +146,11 @@ new_url_patterns = [
|
||||
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="work-item-attachment-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/relations/",
|
||||
IssueRelationListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="work-item-relation-list",
|
||||
),
|
||||
]
|
||||
|
||||
urlpatterns = old_url_patterns + new_url_patterns
|
||||
|
||||
@@ -6,6 +6,7 @@ from .project import (
|
||||
ProjectListCreateAPIEndpoint,
|
||||
ProjectDetailAPIEndpoint,
|
||||
ProjectArchiveUnarchiveAPIEndpoint,
|
||||
ProjectSummaryAPIEndpoint,
|
||||
)
|
||||
|
||||
from .state import (
|
||||
@@ -28,6 +29,7 @@ from .issue import (
|
||||
IssueAttachmentListCreateAPIEndpoint,
|
||||
IssueAttachmentDetailAPIEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
IssueRelationListCreateAPIEndpoint,
|
||||
)
|
||||
|
||||
from .cycle import (
|
||||
|
||||
@@ -17,6 +17,7 @@ from drf_spectacular.utils import OpenApiExample, OpenApiRequest
|
||||
# Module Imports
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.path_validator import sanitize_filename
|
||||
from plane.db.models import FileAsset, User, Workspace
|
||||
from plane.api.views.base import BaseAPIView
|
||||
from plane.api.serializers import (
|
||||
@@ -114,7 +115,7 @@ class UserAssetEndpoint(BaseAPIView):
|
||||
This endpoint generates the necessary credentials for direct S3 upload.
|
||||
"""
|
||||
# get the asset key
|
||||
name = request.data.get("name")
|
||||
name = sanitize_filename(request.data.get("name")) or "unnamed"
|
||||
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)
|
||||
@@ -287,7 +288,7 @@ class UserServerAssetEndpoint(BaseAPIView):
|
||||
necessary credentials for direct S3 upload with server-side authentication.
|
||||
"""
|
||||
# get the asset key
|
||||
name = request.data.get("name")
|
||||
name = sanitize_filename(request.data.get("name")) or "unnamed"
|
||||
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)
|
||||
@@ -498,7 +499,7 @@ class GenericAssetEndpoint(BaseAPIView):
|
||||
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")
|
||||
name = sanitize_filename(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")
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
|
||||
|
||||
# Module imports
|
||||
from plane.app.permissions.project import ProjectEntityPermission
|
||||
from plane.api.views.base import BaseAPIView
|
||||
from plane.db.models import Estimate, EstimatePoint, Project, Workspace
|
||||
from plane.api.serializers import EstimateSerializer, EstimatePointSerializer
|
||||
from plane.utils.openapi.decorators import estimate_docs, estimate_point_docs
|
||||
from plane.utils.openapi import (
|
||||
ESTIMATE_CREATE_EXAMPLE,
|
||||
ESTIMATE_UPDATE_EXAMPLE,
|
||||
ESTIMATE_POINT_CREATE_EXAMPLE,
|
||||
ESTIMATE_POINT_UPDATE_EXAMPLE,
|
||||
ESTIMATE_EXAMPLE,
|
||||
ESTIMATE_POINT_EXAMPLE,
|
||||
DELETED_RESPONSE,
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PROJECT_ID_PARAMETER,
|
||||
ESTIMATE_ID_PARAMETER,
|
||||
)
|
||||
|
||||
|
||||
class ProjectEstimateAPIEndpoint(BaseAPIView):
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
model = Estimate
|
||||
serializer_class = EstimateSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return self.model.objects.filter(workspace__slug=self.workspace_slug, project_id=self.project_id)
|
||||
|
||||
@estimate_docs(
|
||||
operation_id="create_estimate",
|
||||
summary="Create an estimate",
|
||||
description="Create an estimate for a project",
|
||||
request=OpenApiRequest(
|
||||
request=EstimateSerializer,
|
||||
examples=[ESTIMATE_CREATE_EXAMPLE],
|
||||
),
|
||||
)
|
||||
def post(self, request, slug, project_id):
|
||||
project = Project.objects.filter(id=project_id, workspace__slug=slug).first()
|
||||
if not project:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Project not found"})
|
||||
|
||||
workspace = Workspace.objects.filter(slug=slug).first()
|
||||
if not workspace:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Workspace not found"})
|
||||
|
||||
project_estimate = self.get_queryset().first()
|
||||
if project_estimate:
|
||||
# return 409 if the project estimate already exists
|
||||
return Response(
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
data={"error": "An estimate already exists for this project", "id": str(project_estimate.id)},
|
||||
)
|
||||
# create the project estimate
|
||||
serializer = self.serializer_class(data=request.data, context={"workspace": workspace, "project": project})
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@estimate_docs(
|
||||
operation_id="get_estimate",
|
||||
summary="Get an estimate",
|
||||
description="Get an estimate for a project",
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="Estimate",
|
||||
response=EstimateSerializer,
|
||||
examples=[ESTIMATE_EXAMPLE],
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, request, slug, project_id):
|
||||
estimate = self.get_queryset().first()
|
||||
if not estimate:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
|
||||
serializer = self.serializer_class(estimate)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@estimate_docs(
|
||||
operation_id="update_estimate",
|
||||
summary="Update an estimate",
|
||||
description="Update an estimate for a project",
|
||||
request=OpenApiRequest(
|
||||
request=EstimateSerializer,
|
||||
examples=[ESTIMATE_UPDATE_EXAMPLE],
|
||||
),
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="Estimate",
|
||||
response=EstimateSerializer,
|
||||
examples=[ESTIMATE_EXAMPLE],
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, request, slug, project_id):
|
||||
ALLOWED_FIELDS = ["name", "description"]
|
||||
estimate = self.get_queryset().first()
|
||||
if not estimate:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
|
||||
filtered_data = {k: v for k, v in request.data.items() if k in ALLOWED_FIELDS}
|
||||
if not filtered_data:
|
||||
serializer = self.serializer_class(estimate)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
serializer = self.serializer_class(estimate, data=filtered_data, partial=True)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@estimate_docs(
|
||||
operation_id="delete_estimate",
|
||||
summary="Delete an estimate",
|
||||
description="Delete an estimate for a project",
|
||||
responses={
|
||||
204: DELETED_RESPONSE,
|
||||
},
|
||||
)
|
||||
def delete(self, request, slug, project_id):
|
||||
estimate = self.get_queryset().first()
|
||||
if not estimate:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
|
||||
estimate.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class EstimatePointListCreateAPIEndpoint(BaseAPIView):
|
||||
"""List and bulk create estimate points for an estimate."""
|
||||
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
model = EstimatePoint
|
||||
serializer_class = EstimatePointSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return self.model.objects.filter(
|
||||
estimate_id=self.kwargs["estimate_id"],
|
||||
workspace__slug=self.kwargs["slug"],
|
||||
project_id=self.kwargs["project_id"],
|
||||
).select_related("estimate", "workspace", "project")
|
||||
|
||||
@estimate_point_docs(
|
||||
operation_id="get_estimate_points",
|
||||
summary="Get estimate points",
|
||||
description="Get estimate points for an estimate",
|
||||
parameters=[
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PROJECT_ID_PARAMETER,
|
||||
ESTIMATE_ID_PARAMETER,
|
||||
],
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="Estimate points",
|
||||
response=EstimatePointSerializer(many=True),
|
||||
examples=[ESTIMATE_POINT_EXAMPLE],
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, request, slug, project_id, estimate_id):
|
||||
estimate = Estimate.objects.filter(
|
||||
id=estimate_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
if not estimate:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
|
||||
estimate_points = self.get_queryset()
|
||||
serializer = self.serializer_class(estimate_points, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@estimate_point_docs(
|
||||
operation_id="create_estimate_points",
|
||||
summary="Create estimate points",
|
||||
description="Create estimate points for an estimate",
|
||||
request=OpenApiRequest(
|
||||
request=EstimatePointSerializer,
|
||||
examples=[ESTIMATE_POINT_CREATE_EXAMPLE],
|
||||
),
|
||||
responses={
|
||||
201: OpenApiResponse(
|
||||
description="Estimate points",
|
||||
response=EstimatePointSerializer(many=True),
|
||||
examples=[ESTIMATE_POINT_EXAMPLE],
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self, request, slug, project_id, estimate_id):
|
||||
estimate = Estimate.objects.filter(
|
||||
id=estimate_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
if not estimate:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
|
||||
|
||||
estimate_points_data = (
|
||||
request.data if isinstance(request.data, list) else request.data.get("estimate_points", [])
|
||||
)
|
||||
if not estimate_points_data:
|
||||
return Response(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
data={"error": "Estimate points are required"},
|
||||
)
|
||||
|
||||
serializer = self.serializer_class(data=estimate_points_data, many=True)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
estimate_points = [
|
||||
EstimatePoint(
|
||||
estimate=estimate,
|
||||
workspace=estimate.workspace,
|
||||
project=estimate.project,
|
||||
**item,
|
||||
)
|
||||
for item in serializer.validated_data
|
||||
]
|
||||
created = EstimatePoint.objects.bulk_create(estimate_points)
|
||||
return Response(
|
||||
self.serializer_class(created, many=True).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class EstimatePointDetailAPIEndpoint(BaseAPIView):
|
||||
"""Update and delete a single estimate point."""
|
||||
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
model = EstimatePoint
|
||||
serializer_class = EstimatePointSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return self.model.objects.filter(
|
||||
estimate_id=self.kwargs["estimate_id"],
|
||||
workspace__slug=self.kwargs["slug"],
|
||||
project_id=self.kwargs["project_id"],
|
||||
)
|
||||
|
||||
@estimate_point_docs(
|
||||
operation_id="update_estimate_point",
|
||||
summary="Update an estimate point",
|
||||
description="Update an estimate point for an estimate",
|
||||
request=OpenApiRequest(
|
||||
request=EstimatePointSerializer,
|
||||
examples=[ESTIMATE_POINT_UPDATE_EXAMPLE],
|
||||
),
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="Estimate point",
|
||||
response=EstimatePointSerializer,
|
||||
examples=[ESTIMATE_POINT_EXAMPLE],
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, request, slug, project_id, estimate_id, estimate_point_id):
|
||||
estimate_point = self.get_queryset().filter(id=estimate_point_id).first()
|
||||
if not estimate_point:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate point not found"})
|
||||
ALLOWED_FIELDS = ["key", "value", "description"]
|
||||
filtered_data = {k: v for k, v in request.data.items() if k in ALLOWED_FIELDS}
|
||||
if not filtered_data:
|
||||
return Response(self.serializer_class(estimate_point).data, status=status.HTTP_200_OK)
|
||||
serializer = self.serializer_class(estimate_point, data=filtered_data, partial=True)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@estimate_point_docs(
|
||||
operation_id="delete_estimate_point",
|
||||
summary="Delete an estimate point",
|
||||
description="Delete an estimate point for an estimate",
|
||||
responses={
|
||||
204: DELETED_RESPONSE,
|
||||
},
|
||||
)
|
||||
def delete(self, request, slug, project_id, estimate_id, estimate_point_id):
|
||||
estimate_point = self.get_queryset().filter(id=estimate_point_id).first()
|
||||
if not estimate_point:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate point not found"})
|
||||
estimate_point.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -24,6 +24,7 @@ from django.db.models import (
|
||||
When,
|
||||
Subquery,
|
||||
)
|
||||
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
@@ -45,6 +46,9 @@ from plane.api.serializers import (
|
||||
IssueActivitySerializer,
|
||||
IssueCommentSerializer,
|
||||
IssueLinkSerializer,
|
||||
IssueRelationCreateSerializer,
|
||||
IssueRelationResponseSerializer,
|
||||
IssueRelationSerializer,
|
||||
IssueSerializer,
|
||||
LabelSerializer,
|
||||
IssueAttachmentUploadSerializer,
|
||||
@@ -53,6 +57,7 @@ from plane.api.serializers import (
|
||||
IssueLinkCreateSerializer,
|
||||
IssueLinkUpdateSerializer,
|
||||
LabelCreateUpdateSerializer,
|
||||
RelatedIssueSerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
@@ -66,6 +71,7 @@ from plane.db.models import (
|
||||
FileAsset,
|
||||
IssueComment,
|
||||
IssueLink,
|
||||
IssueRelation,
|
||||
Label,
|
||||
Project,
|
||||
ProjectMember,
|
||||
@@ -73,13 +79,16 @@ from plane.db.models import (
|
||||
Workspace,
|
||||
)
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.path_validator import sanitize_filename
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.issue_relation_mapper import get_actual_relation
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.app.permissions import ROLE
|
||||
from plane.utils.openapi import (
|
||||
work_item_docs,
|
||||
work_item_relation_docs,
|
||||
label_docs,
|
||||
issue_link_docs,
|
||||
issue_comment_docs,
|
||||
@@ -629,6 +638,16 @@ class IssueDetailAPIEndpoint(BaseAPIView):
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
# Send the model activity for webhook dispatch
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
model_id=str(issue.id),
|
||||
requested_data=request.data,
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
# If the serializer is not valid, respond with 400 bad
|
||||
@@ -677,6 +696,16 @@ class IssueDetailAPIEndpoint(BaseAPIView):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
# Send the model activity for webhook dispatch
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
model_id=str(serializer.data["id"]),
|
||||
requested_data=request.data,
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
@@ -752,6 +781,16 @@ class IssueDetailAPIEndpoint(BaseAPIView):
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
# Send the model activity for webhook dispatch
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
model_id=str(pk),
|
||||
requested_data=request.data,
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -1089,9 +1128,9 @@ class IssueLinkListCreateAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
on_results=lambda issue_links: IssueLinkSerializer(
|
||||
issue_links, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
on_results=lambda issue_links: (
|
||||
IssueLinkSerializer(issue_links, many=True, fields=self.fields, expand=self.expand).data
|
||||
),
|
||||
)
|
||||
|
||||
@issue_link_docs(
|
||||
@@ -1196,9 +1235,9 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
on_results=lambda issue_links: IssueLinkSerializer(
|
||||
issue_links, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
on_results=lambda issue_links: (
|
||||
IssueLinkSerializer(issue_links, many=True, fields=self.fields, expand=self.expand).data
|
||||
),
|
||||
)
|
||||
issue_link = self.get_queryset().get(pk=pk)
|
||||
serializer = IssueLinkSerializer(issue_link, fields=self.fields, expand=self.expand)
|
||||
@@ -1347,9 +1386,9 @@ class IssueCommentListCreateAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
on_results=lambda issue_comments: IssueCommentSerializer(
|
||||
issue_comments, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
on_results=lambda issue_comments: (
|
||||
IssueCommentSerializer(issue_comments, many=True, fields=self.fields, expand=self.expand).data
|
||||
),
|
||||
)
|
||||
|
||||
@issue_comment_docs(
|
||||
@@ -1658,9 +1697,9 @@ class IssueActivityListAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(issue_activities),
|
||||
on_results=lambda issue_activity: IssueActivitySerializer(
|
||||
issue_activity, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
on_results=lambda issue_activity: (
|
||||
IssueActivitySerializer(issue_activity, many=True, fields=self.fields, expand=self.expand).data
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1820,7 +1859,7 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
name = request.data.get("name")
|
||||
name = sanitize_filename(request.data.get("name"))
|
||||
type = request.data.get("type", False)
|
||||
size = request.data.get("size")
|
||||
external_id = request.data.get("external_id")
|
||||
@@ -2220,3 +2259,284 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
)[: int(limit)]
|
||||
|
||||
return Response({"issues": issue_results}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class IssueRelationListCreateAPIEndpoint(BaseAPIView):
|
||||
"""Issue Relation List and Create Endpoint"""
|
||||
|
||||
serializer_class = IssueRelationSerializer
|
||||
model = IssueRelation
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
use_read_replica = True
|
||||
|
||||
@work_item_relation_docs(
|
||||
operation_id="list_work_item_relations",
|
||||
summary="List work item relations",
|
||||
description="Retrieve all relationships for a work item including blocking, blocked_by, duplicate, relates_to, start_before, start_after, finish_before, and finish_after relations.", # noqa E501
|
||||
parameters=[
|
||||
ISSUE_ID_PARAMETER,
|
||||
CURSOR_PARAMETER,
|
||||
PER_PAGE_PARAMETER,
|
||||
ORDER_BY_PARAMETER,
|
||||
FIELDS_PARAMETER,
|
||||
EXPAND_PARAMETER,
|
||||
],
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="Work item relations grouped by relation type",
|
||||
response=IssueRelationResponseSerializer,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Work Item Relations Response",
|
||||
value={
|
||||
"blocking": [
|
||||
{
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440010",
|
||||
"issue_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
},
|
||||
{
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440010",
|
||||
"issue_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
},
|
||||
],
|
||||
"blocked_by": [
|
||||
{
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440011",
|
||||
"issue_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
},
|
||||
],
|
||||
"duplicate": [],
|
||||
"relates_to": [
|
||||
{
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440010",
|
||||
"issue_id": "550e8400-e29b-41d4-a716-446655440003",
|
||||
},
|
||||
],
|
||||
"start_after": [],
|
||||
"start_before": [
|
||||
{
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440012",
|
||||
"issue_id": "550e8400-e29b-41d4-a716-446655440004",
|
||||
},
|
||||
],
|
||||
"finish_after": [],
|
||||
"finish_before": [],
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
400: INVALID_REQUEST_RESPONSE,
|
||||
404: ISSUE_NOT_FOUND_RESPONSE,
|
||||
},
|
||||
)
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
"""List work item relations
|
||||
|
||||
Retrieve all relationships for a work item organized by relation type.
|
||||
Returns a structured response with relations grouped by type.
|
||||
"""
|
||||
relations = IssueRelation.objects.filter(
|
||||
Q(issue_id=issue_id) | Q(related_issue_id=issue_id),
|
||||
workspace__slug=slug,
|
||||
).values(
|
||||
"relation_type",
|
||||
"issue_id",
|
||||
"related_issue_id",
|
||||
issue_project_id=F("issue__project_id"),
|
||||
related_issue_project_id=F("related_issue__project_id"),
|
||||
)
|
||||
|
||||
response_data = {
|
||||
"blocking": [],
|
||||
"blocked_by": [],
|
||||
"duplicate": [],
|
||||
"relates_to": [],
|
||||
"start_after": [],
|
||||
"start_before": [],
|
||||
"finish_after": [],
|
||||
"finish_before": [],
|
||||
}
|
||||
seen_duplicate = set()
|
||||
seen_relates_to = set()
|
||||
|
||||
for rel in relations:
|
||||
rt = rel["relation_type"]
|
||||
if rt == "blocked_by":
|
||||
if str(rel["related_issue_id"]) == str(issue_id):
|
||||
response_data["blocking"].append(
|
||||
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
|
||||
)
|
||||
if str(rel["issue_id"]) == str(issue_id):
|
||||
response_data["blocked_by"].append(
|
||||
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
|
||||
)
|
||||
elif rt == "duplicate":
|
||||
if str(rel["issue_id"]) == str(issue_id) and rel["related_issue_id"] not in seen_duplicate:
|
||||
seen_duplicate.add(rel["related_issue_id"])
|
||||
response_data["duplicate"].append(
|
||||
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
|
||||
)
|
||||
if str(rel["related_issue_id"]) == str(issue_id) and rel["issue_id"] not in seen_duplicate:
|
||||
seen_duplicate.add(rel["issue_id"])
|
||||
response_data["duplicate"].append(
|
||||
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
|
||||
)
|
||||
elif rt == "relates_to":
|
||||
if str(rel["issue_id"]) == str(issue_id) and rel["related_issue_id"] not in seen_relates_to:
|
||||
seen_relates_to.add(rel["related_issue_id"])
|
||||
response_data["relates_to"].append(
|
||||
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
|
||||
)
|
||||
if str(rel["related_issue_id"]) == str(issue_id) and rel["issue_id"] not in seen_relates_to:
|
||||
seen_relates_to.add(rel["issue_id"])
|
||||
response_data["relates_to"].append(
|
||||
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
|
||||
)
|
||||
elif rt == "start_before":
|
||||
if str(rel["related_issue_id"]) == str(issue_id):
|
||||
response_data["start_after"].append(
|
||||
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
|
||||
)
|
||||
if str(rel["issue_id"]) == str(issue_id):
|
||||
response_data["start_before"].append(
|
||||
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
|
||||
)
|
||||
elif rt == "finish_before":
|
||||
if str(rel["related_issue_id"]) == str(issue_id):
|
||||
response_data["finish_after"].append(
|
||||
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
|
||||
)
|
||||
if str(rel["issue_id"]) == str(issue_id):
|
||||
response_data["finish_before"].append(
|
||||
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
|
||||
)
|
||||
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
@work_item_relation_docs(
|
||||
operation_id="create_work_item_relation",
|
||||
summary="Create work item relation",
|
||||
description="Create relationships between work items. Supports various relation types including blocking, blocked_by, duplicate, relates_to, start_before, start_after, finish_before, and finish_after.", # noqa E501
|
||||
parameters=[
|
||||
ISSUE_ID_PARAMETER,
|
||||
],
|
||||
request=OpenApiRequest(
|
||||
request=IssueRelationCreateSerializer,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Create blocking relation",
|
||||
value={
|
||||
"relation_type": "blocking",
|
||||
"issues": [
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"550e8400-e29b-41d4-a716-446655440001",
|
||||
],
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
responses={
|
||||
201: OpenApiResponse(
|
||||
description="Work item relations created successfully",
|
||||
response=IssueRelationSerializer(many=True),
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Relations created",
|
||||
value=[
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Fix authentication bug",
|
||||
"sequence_id": 42,
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"relation_type": "blocked_by",
|
||||
"state_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"priority": "high",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T10:00:00Z",
|
||||
"created_by": "550e8400-e29b-41d4-a716-446655440004",
|
||||
"updated_by": "550e8400-e29b-41d4-a716-446655440004",
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
400: INVALID_REQUEST_RESPONSE,
|
||||
404: ISSUE_NOT_FOUND_RESPONSE,
|
||||
},
|
||||
)
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
"""Create work item relation
|
||||
|
||||
Create relationships between work items with specified relation type.
|
||||
Automatically tracks relation creation activity.
|
||||
"""
|
||||
# Validate request data using serializer
|
||||
serializer = IssueRelationCreateSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
relation_type = serializer.validated_data["relation_type"]
|
||||
issues = serializer.validated_data["issues"]
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
|
||||
actual_relation = get_actual_relation(relation_type)
|
||||
is_reverse = relation_type in ["blocking", "start_after", "finish_after"]
|
||||
|
||||
IssueRelation.objects.bulk_create(
|
||||
[
|
||||
IssueRelation(
|
||||
issue_id=(issue if is_reverse else issue_id),
|
||||
related_issue_id=(issue_id if is_reverse else issue),
|
||||
relation_type=actual_relation,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for issue in issues
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
issue_activity.delay(
|
||||
type="issue_relation.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
# Re-fetch with select_related to avoid N+1 queries in serializers.
|
||||
# bulk_create with ignore_conflicts=True may not return PKs,
|
||||
# so query by the issue/related_issue pairs and relation type.
|
||||
if is_reverse:
|
||||
refetch_filter = Q(
|
||||
issue_id__in=issues,
|
||||
related_issue_id=issue_id,
|
||||
relation_type=actual_relation,
|
||||
)
|
||||
else:
|
||||
refetch_filter = Q(
|
||||
issue_id=issue_id,
|
||||
related_issue_id__in=issues,
|
||||
relation_type=actual_relation,
|
||||
)
|
||||
|
||||
refetched_relations = IssueRelation.objects.filter(
|
||||
refetch_filter,
|
||||
workspace__slug=slug,
|
||||
).select_related(
|
||||
"issue__state",
|
||||
"related_issue__state",
|
||||
)
|
||||
|
||||
serializer_class = RelatedIssueSerializer if is_reverse else IssueRelationSerializer
|
||||
return Response(
|
||||
serializer_class(refetched_relations, many=True).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@@ -7,7 +7,8 @@ import json
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery
|
||||
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery, Count
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
@@ -22,7 +23,6 @@ from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
Intake,
|
||||
ProjectUserProperty,
|
||||
Module,
|
||||
Project,
|
||||
DeployBoard,
|
||||
@@ -31,6 +31,11 @@ from plane.db.models import (
|
||||
DEFAULT_STATES,
|
||||
Workspace,
|
||||
UserFavorite,
|
||||
Label,
|
||||
Issue,
|
||||
StateGroup,
|
||||
IntakeIssue,
|
||||
ProjectPage,
|
||||
)
|
||||
from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from .base import BaseAPIView
|
||||
@@ -40,7 +45,7 @@ from plane.api.serializers import (
|
||||
ProjectCreateSerializer,
|
||||
ProjectUpdateSerializer,
|
||||
)
|
||||
from plane.app.permissions import ProjectBasePermission
|
||||
from plane.app.permissions import ProjectBasePermission, WorkSpaceAdminPermission
|
||||
from plane.utils.openapi import (
|
||||
project_docs,
|
||||
PROJECT_ID_PARAMETER,
|
||||
@@ -183,9 +188,9 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(projects),
|
||||
on_results=lambda projects: ProjectSerializer(
|
||||
projects, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
on_results=lambda projects: (
|
||||
ProjectSerializer(projects, many=True, fields=self.fields, expand=self.expand).data
|
||||
),
|
||||
)
|
||||
|
||||
@project_docs(
|
||||
@@ -549,3 +554,119 @@ class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
project.archived_at = None
|
||||
project.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
ALLOWED_PROJECT_SUMMARY_FIELDS = [
|
||||
"members",
|
||||
"states",
|
||||
"labels",
|
||||
"cycles",
|
||||
"modules",
|
||||
"issues",
|
||||
"intakes",
|
||||
"pages",
|
||||
]
|
||||
|
||||
|
||||
class ProjectSummaryAPIEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkSpaceAdminPermission]
|
||||
use_read_replica = True
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
"""Get project summary
|
||||
|
||||
Get the summary of a project
|
||||
"""
|
||||
project = Project.objects.filter(pk=project_id, workspace__slug=slug).first()
|
||||
if not project:
|
||||
return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
fields = request.GET.get("fields", "").split(",")
|
||||
requested_fields = set(filter(None, (f.strip() for f in fields))) & set(ALLOWED_PROJECT_SUMMARY_FIELDS)
|
||||
if not requested_fields:
|
||||
requested_fields = set(ALLOWED_PROJECT_SUMMARY_FIELDS)
|
||||
|
||||
# Single DB round-trip with only requested count subqueries
|
||||
counts = self._get_all_summary_counts(project_id, requested_fields)
|
||||
counts_dict = {field: counts[field] for field in requested_fields}
|
||||
summary = {
|
||||
"id": project.id,
|
||||
"name": project.name,
|
||||
"identifier": project.identifier,
|
||||
"counts": counts_dict,
|
||||
}
|
||||
return Response(summary, status=status.HTTP_200_OK)
|
||||
|
||||
# Getting all summary counts in one ORM query; only runs subqueries for requested fields.
|
||||
def _get_all_summary_counts(self, project_id, requested_fields):
|
||||
"""Return requested summary counts in one ORM query; only runs subqueries for requested fields."""
|
||||
|
||||
# Using a different annotation name for 'pages' to avoid conflict with Project.pages (M2M from Page)
|
||||
def _annotation_name(field):
|
||||
return "pages_count" if field == "pages" else field
|
||||
|
||||
subquery_builders = {
|
||||
"members": lambda: (
|
||||
ProjectMember.objects.filter(project_id=OuterRef("pk"), is_active=True)
|
||||
.values("project_id")
|
||||
.annotate(count=Count("*"))
|
||||
.values("count")
|
||||
),
|
||||
"states": lambda: (
|
||||
State.objects.filter(project_id=OuterRef("pk"))
|
||||
.values("project_id")
|
||||
.annotate(count=Count("*"))
|
||||
.values("count")
|
||||
),
|
||||
"labels": lambda: (
|
||||
Label.objects.filter(project_id=OuterRef("pk"))
|
||||
.values("project_id")
|
||||
.annotate(count=Count("*"))
|
||||
.values("count")
|
||||
),
|
||||
"cycles": lambda: (
|
||||
Cycle.objects.filter(project_id=OuterRef("pk"))
|
||||
.values("project_id")
|
||||
.annotate(count=Count("*"))
|
||||
.values("count")
|
||||
),
|
||||
"modules": lambda: (
|
||||
Module.objects.filter(project_id=OuterRef("pk"))
|
||||
.values("project_id")
|
||||
.annotate(count=Count("*"))
|
||||
.values("count")
|
||||
),
|
||||
"issues": lambda: (
|
||||
Issue.objects.filter(project_id=OuterRef("pk"))
|
||||
.exclude(state__group=StateGroup.TRIAGE.value)
|
||||
.values("project_id")
|
||||
.annotate(count=Count("*"))
|
||||
.values("count")
|
||||
),
|
||||
"intakes": lambda: (
|
||||
IntakeIssue.objects.filter(project_id=OuterRef("pk"))
|
||||
.values("project_id")
|
||||
.annotate(count=Count("*"))
|
||||
.values("count")
|
||||
),
|
||||
"pages": lambda: (
|
||||
ProjectPage.objects.filter(project_id=OuterRef("pk"))
|
||||
.values("project_id")
|
||||
.annotate(count=Count("*"))
|
||||
.values("count")
|
||||
),
|
||||
}
|
||||
|
||||
# Build annotations dictionary for the requested fields
|
||||
annotations = {
|
||||
_annotation_name(field): Coalesce(Subquery(subquery_builders[field]()), 0) for field in requested_fields
|
||||
}
|
||||
|
||||
# Prepare values list for the annotation names
|
||||
fields_list = sorted(requested_fields)
|
||||
values_list = [_annotation_name(f) for f in fields_list]
|
||||
# Execute the query and get the result
|
||||
query_result = Project.objects.filter(pk=project_id).annotate(**annotations).values(*values_list).first()
|
||||
if not query_result:
|
||||
return {field: 0 for field in requested_fields}
|
||||
# Return the result as a dictionary
|
||||
return {field: query_result[_annotation_name(field)] for field in requested_fields}
|
||||
|
||||
@@ -22,6 +22,17 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
|
||||
def _wrapped_view(instance, request, *args, **kwargs):
|
||||
# Check for creator if required
|
||||
if creator and model:
|
||||
# check if the user is part of the workspace or not
|
||||
if not WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
is_active=True,
|
||||
).exists():
|
||||
return Response(
|
||||
{"error": "You don't have the required permissions."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
obj = model.objects.filter(id=kwargs["pk"], created_by=request.user).exists()
|
||||
if obj:
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
@@ -3,90 +3,66 @@
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import socket
|
||||
import ipaddress
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from .base import DynamicBaseSerializer
|
||||
from plane.db.models import Webhook, WebhookLog
|
||||
from plane.db.models.webhook import validate_domain, validate_schema
|
||||
from plane.utils.ip_address import validate_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebhookSerializer(DynamicBaseSerializer):
|
||||
url = serializers.URLField(validators=[validate_schema, validate_domain])
|
||||
|
||||
def create(self, validated_data):
|
||||
url = validated_data.get("url", None)
|
||||
|
||||
# Extract the hostname from the URL
|
||||
hostname = urlparse(url).hostname
|
||||
if not hostname:
|
||||
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
|
||||
|
||||
# Resolve the hostname to IP addresses
|
||||
def _validate_webhook_url(self, url):
|
||||
"""Validate a webhook URL against SSRF and disallowed domain rules."""
|
||||
try:
|
||||
ip_addresses = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror:
|
||||
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
|
||||
validate_url(
|
||||
url,
|
||||
allowed_ips=settings.WEBHOOK_ALLOWED_IPS,
|
||||
allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning("Webhook URL validation failed for %s: %s", url, e)
|
||||
raise serializers.ValidationError({"url": "Invalid or disallowed webhook URL."})
|
||||
|
||||
if not ip_addresses:
|
||||
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
|
||||
hostname = (urlparse(url).hostname or "").rstrip(".").lower()
|
||||
|
||||
for addr in ip_addresses:
|
||||
ip = ipaddress.ip_address(addr[4][0])
|
||||
if ip.is_loopback:
|
||||
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
|
||||
# Hosts explicitly trusted via WEBHOOK_ALLOWED_HOSTS bypass the
|
||||
# disallowed-domain check — they're already trusted for SSRF, so
|
||||
# the loop-back guard would only get in the way of legitimate
|
||||
# sibling services that share a parent domain with Plane.
|
||||
if hostname in settings.WEBHOOK_ALLOWED_HOSTS:
|
||||
return
|
||||
|
||||
# Additional validation for multiple request domains and their subdomains
|
||||
request = self.context.get("request")
|
||||
disallowed_domains = ["plane.so"] # Add your disallowed domains here
|
||||
disallowed_domains = list(settings.WEBHOOK_DISALLOWED_DOMAINS)
|
||||
if request:
|
||||
request_host = request.get_host().split(":")[0] # Remove port if present
|
||||
request_host = request.get_host().split(":")[0].rstrip(".").lower()
|
||||
disallowed_domains.append(request_host)
|
||||
|
||||
# Check if hostname is a subdomain or exact match of any disallowed domain
|
||||
if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
|
||||
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
|
||||
|
||||
def create(self, validated_data):
|
||||
url = validated_data.get("url", None)
|
||||
self._validate_webhook_url(url)
|
||||
return Webhook.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
url = validated_data.get("url", None)
|
||||
if url:
|
||||
# Extract the hostname from the URL
|
||||
hostname = urlparse(url).hostname
|
||||
if not hostname:
|
||||
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
|
||||
|
||||
# Resolve the hostname to IP addresses
|
||||
try:
|
||||
ip_addresses = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror:
|
||||
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
|
||||
|
||||
if not ip_addresses:
|
||||
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
|
||||
|
||||
for addr in ip_addresses:
|
||||
ip = ipaddress.ip_address(addr[4][0])
|
||||
if ip.is_loopback:
|
||||
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
|
||||
|
||||
# Additional validation for multiple request domains and their subdomains
|
||||
request = self.context.get("request")
|
||||
disallowed_domains = ["plane.so"] # Add your disallowed domains here
|
||||
if request:
|
||||
request_host = request.get_host().split(":")[0] # Remove port if present
|
||||
disallowed_domains.append(request_host)
|
||||
|
||||
# Check if hostname is a subdomain or exact match of any disallowed domain
|
||||
if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
|
||||
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
|
||||
|
||||
self._validate_webhook_url(url)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from django.urls import path
|
||||
from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
|
||||
from plane.app.views import ApiTokenEndpoint
|
||||
|
||||
urlpatterns = [
|
||||
# API Tokens
|
||||
@@ -17,10 +17,5 @@ urlpatterns = [
|
||||
ApiTokenEndpoint.as_view(),
|
||||
name="api-tokens-details",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/service-api-tokens/",
|
||||
ServiceApiTokenEndpoint.as_view(),
|
||||
name="service-api-tokens",
|
||||
),
|
||||
## End API Tokens
|
||||
]
|
||||
|
||||
@@ -165,7 +165,7 @@ from .module.issue import ModuleIssueViewSet
|
||||
|
||||
from .module.archive import ModuleArchiveUnarchiveEndpoint
|
||||
|
||||
from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint
|
||||
from .api import ApiTokenEndpoint
|
||||
|
||||
from .page.base import (
|
||||
PageViewSet,
|
||||
|
||||
@@ -29,7 +29,7 @@ from plane.db.models import (
|
||||
Module,
|
||||
)
|
||||
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.utils.analytics_plot import build_graph_plot, VALID_ANALYTICS_FIELDS, VALID_YAXIS
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
|
||||
@@ -41,32 +41,15 @@ class AnalyticsEndpoint(BaseAPIView):
|
||||
y_axis = request.GET.get("y_axis", False)
|
||||
segment = request.GET.get("segment", False)
|
||||
|
||||
valid_xaxis_segment = [
|
||||
"state_id",
|
||||
"state__group",
|
||||
"labels__id",
|
||||
"assignees__id",
|
||||
"estimate_point__value",
|
||||
"issue_cycle__cycle_id",
|
||||
"issue_module__module_id",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"created_at",
|
||||
"completed_at",
|
||||
]
|
||||
|
||||
valid_yaxis = ["issue_count", "estimate"]
|
||||
|
||||
# Check for x-axis and y-axis as thery are required parameters
|
||||
if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis:
|
||||
if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
|
||||
return Response(
|
||||
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# If segment is present it cannot be same as x-axis
|
||||
if segment and (segment not in valid_xaxis_segment or x_axis == segment):
|
||||
if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
|
||||
return Response(
|
||||
{"error": "Both segment and x axis cannot be same and segment should be valid"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -214,13 +197,20 @@ class SavedAnalyticEndpoint(BaseAPIView):
|
||||
x_axis = analytic_view.query_dict.get("x_axis", False)
|
||||
y_axis = analytic_view.query_dict.get("y_axis", False)
|
||||
|
||||
if not x_axis or not y_axis:
|
||||
if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
|
||||
return Response(
|
||||
{"error": "x-axis and y-axis dimensions are required"},
|
||||
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
segment = request.GET.get("segment", False)
|
||||
|
||||
if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
|
||||
return Response(
|
||||
{"error": "Both segment and x axis cannot be same and segment should be valid"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
distribution = build_graph_plot(queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment)
|
||||
total_issues = queryset.count()
|
||||
return Response(
|
||||
@@ -236,32 +226,15 @@ class ExportAnalyticsEndpoint(BaseAPIView):
|
||||
y_axis = request.data.get("y_axis", False)
|
||||
segment = request.data.get("segment", False)
|
||||
|
||||
valid_xaxis_segment = [
|
||||
"state_id",
|
||||
"state__group",
|
||||
"labels__id",
|
||||
"assignees__id",
|
||||
"estimate_point",
|
||||
"issue_cycle__cycle_id",
|
||||
"issue_module__module_id",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"created_at",
|
||||
"completed_at",
|
||||
]
|
||||
|
||||
valid_yaxis = ["issue_count", "estimate"]
|
||||
|
||||
# Check for x-axis and y-axis as thery are required parameters
|
||||
if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis:
|
||||
if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
|
||||
return Response(
|
||||
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# If segment is present it cannot be same as x-axis
|
||||
if segment and (segment not in valid_xaxis_segment or x_axis == segment):
|
||||
if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
|
||||
return Response(
|
||||
{"error": "Both segment and x axis cannot be same and segment should be valid"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -13,9 +13,8 @@ from rest_framework import status
|
||||
|
||||
# Module import
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models import APIToken, Workspace
|
||||
from plane.db.models import APIToken
|
||||
from plane.app.serializers import APITokenSerializer, APITokenReadSerializer
|
||||
from plane.app.permissions import WorkspaceEntityPermission
|
||||
|
||||
|
||||
class ApiTokenEndpoint(BaseAPIView):
|
||||
@@ -61,28 +60,3 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ServiceApiTokenEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
|
||||
def post(self, request: Request, slug: str) -> Response:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
api_token = APIToken.objects.filter(workspace=workspace, is_service=True).first()
|
||||
|
||||
if api_token:
|
||||
return Response({"token": str(api_token.token)}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
# Check the user type
|
||||
user_type = 1 if request.user.is_bot else 0
|
||||
|
||||
api_token = APIToken.objects.create(
|
||||
label=str(uuid4().hex),
|
||||
description="Service Token",
|
||||
user=request.user,
|
||||
workspace=workspace,
|
||||
user_type=user_type,
|
||||
is_service=True,
|
||||
)
|
||||
return Response({"token": str(api_token.token)}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -18,10 +18,11 @@ from rest_framework.permissions import AllowAny
|
||||
|
||||
# Module imports
|
||||
from ..base import BaseAPIView
|
||||
from plane.db.models import FileAsset, Workspace, Project, User
|
||||
from plane.db.models import FileAsset, Workspace, Project, User, WorkspaceMember
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.utils.cache import invalidate_cache_directly
|
||||
from plane.utils.path_validator import sanitize_filename
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.throttles.asset import AssetRateThrottle
|
||||
|
||||
@@ -108,7 +109,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
|
||||
def post(self, request):
|
||||
# get the asset key
|
||||
name = request.data.get("name")
|
||||
name = sanitize_filename(request.data.get("name")) or "unnamed"
|
||||
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)
|
||||
@@ -311,8 +312,9 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
else:
|
||||
return
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def post(self, request, slug):
|
||||
name = request.data.get("name")
|
||||
name = sanitize_filename(request.data.get("name")) or "unnamed"
|
||||
type = request.data.get("type", "image/jpeg")
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
entity_type = request.data.get("entity_type")
|
||||
@@ -376,6 +378,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def patch(self, request, slug, asset_id):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
|
||||
@@ -397,6 +400,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
asset.save(update_fields=["is_uploaded", "attributes"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def delete(self, request, slug, asset_id):
|
||||
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
|
||||
asset.is_deleted = True
|
||||
@@ -406,6 +410,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def get(self, request, slug, asset_id):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
|
||||
@@ -511,7 +516,7 @@ class ProjectAssetEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def post(self, request, slug, project_id):
|
||||
name = request.data.get("name")
|
||||
name = sanitize_filename(request.data.get("name")) or "unnamed"
|
||||
type = request.data.get("type", "image/jpeg")
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
entity_type = request.data.get("entity_type", "")
|
||||
@@ -752,12 +757,22 @@ class DuplicateAssetEndpoint(BaseAPIView):
|
||||
return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
storage = S3Storage(request=request)
|
||||
original_asset = FileAsset.objects.filter(id=asset_id, is_uploaded=True).first()
|
||||
# Scope the source asset lookup to workspaces the caller is a member of
|
||||
user_workspace_ids = WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
).values_list("workspace_id", flat=True)
|
||||
original_asset = FileAsset.objects.filter(
|
||||
id=asset_id,
|
||||
is_uploaded=True,
|
||||
workspace_id__in=user_workspace_ids,
|
||||
).first()
|
||||
|
||||
if not original_asset:
|
||||
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
|
||||
sanitized_name = sanitize_filename(original_asset.attributes.get("name")) or "unnamed"
|
||||
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{sanitized_name}"
|
||||
duplicated_asset = FileAsset.objects.create(
|
||||
attributes={
|
||||
"name": original_asset.attributes.get("name"),
|
||||
|
||||
@@ -24,6 +24,7 @@ from plane.db.models import FileAsset, Workspace
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.path_validator import sanitize_filename
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.utils.host import base_host
|
||||
|
||||
@@ -64,7 +65,10 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, issue_id=issue_id
|
||||
).first()
|
||||
if not issue_attachment:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(
|
||||
{"error": "Issue attachment not found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
issue_attachment.asset.delete(save=False)
|
||||
issue_attachment.delete()
|
||||
issue_activity.delay(
|
||||
@@ -94,7 +98,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
name = request.data.get("name")
|
||||
name = sanitize_filename(request.data.get("name")) or "unnamed"
|
||||
type = request.data.get("type", False)
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
|
||||
|
||||
@@ -1118,7 +1118,7 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView):
|
||||
epoch = int(timezone.now().timestamp())
|
||||
|
||||
# Fetch all relevant issues in a single query
|
||||
issues = list(Issue.objects.filter(id__in=issue_ids))
|
||||
issues = list(Issue.objects.filter(id__in=issue_ids, workspace__slug=slug, project_id=project_id))
|
||||
issues_dict = {str(issue.id): issue for issue in issues}
|
||||
issues_to_update = []
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import OuterRef, Func, F, Q, Value, UUIDField, Subquery
|
||||
from django.db.models import OuterRef, Func, F, Q, Value, UUIDField, Subquery, Count, IntegerField
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
@@ -22,7 +22,7 @@ from rest_framework import status
|
||||
from .. import BaseAPIView
|
||||
from plane.app.serializers import IssueSerializer
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue
|
||||
from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue, IssueLabel, IssueAssignee, ModuleIssue
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
from collections import defaultdict
|
||||
@@ -37,70 +37,97 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
sub_issues = (
|
||||
Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
.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,
|
||||
link_count=Coalesce(
|
||||
Subquery(
|
||||
IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.values("issue")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count"),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
0,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
attachment_count=Coalesce(
|
||||
Subquery(
|
||||
FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.values("issue_id")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count"),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
0,
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Coalesce(
|
||||
Subquery(
|
||||
Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.values("parent")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count"),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
0,
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)),
|
||||
Subquery(
|
||||
IssueLabel.objects.filter(issue_id=OuterRef("id"), deleted_at__isnull=True)
|
||||
.order_by()
|
||||
.values("issue_id")
|
||||
.annotate(arr=ArrayAgg("label_id", distinct=True))
|
||||
.values("arr"),
|
||||
output_field=ArrayField(UUIDField()),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(assignees__id__isnull=True)
|
||||
& Q(assignees__member_project__is_active=True)
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
Subquery(
|
||||
IssueAssignee.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
assignee__member_project__is_active=True,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.order_by()
|
||||
.values("issue_id")
|
||||
.annotate(arr=ArrayAgg("assignee_id", distinct=True))
|
||||
.values("arr"),
|
||||
output_field=ArrayField(UUIDField()),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
module_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
),
|
||||
Subquery(
|
||||
ModuleIssue.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
module__archived_at__isnull=True,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.order_by()
|
||||
.values("issue_id")
|
||||
.annotate(arr=ArrayAgg("module_id", distinct=True))
|
||||
.values("arr"),
|
||||
output_field=ArrayField(UUIDField()),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.annotate(state_group=F("state__group"))
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
# Ordering
|
||||
@@ -110,38 +137,42 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
if order_by_param:
|
||||
sub_issues, order_by_param = order_issue_queryset(sub_issues, order_by_param)
|
||||
|
||||
sub_issues = list(
|
||||
sub_issues.values(
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
"sub_issues_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"attachment_count",
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
"state_group",
|
||||
)
|
||||
)
|
||||
|
||||
# create's a dict with state group name with their respective issue id's
|
||||
result = defaultdict(list)
|
||||
for sub_issue in sub_issues:
|
||||
result[sub_issue.state_group].append(str(sub_issue.id))
|
||||
result[sub_issue["state_group"]].append(str(sub_issue["id"]))
|
||||
|
||||
sub_issues = sub_issues.values(
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
"sub_issues_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"attachment_count",
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
)
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
sub_issues = user_timezone_converter(sub_issues, datetime_fields, request.user.user_timezone)
|
||||
# Grouping
|
||||
|
||||
@@ -50,7 +50,7 @@ from plane.utils.error_codes import ERROR_CODES
|
||||
# Local imports
|
||||
from ..base import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.page_transaction_task import page_transaction
|
||||
from plane.bgtasks.page_version_task import page_version
|
||||
from plane.bgtasks.page_version_task import track_page_version
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets
|
||||
from plane.app.permissions import ProjectPagePermission
|
||||
@@ -545,26 +545,28 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Store the old description_html before saving (needed for both tasks)
|
||||
old_description_html = page.description_html
|
||||
|
||||
# Serialize the existing instance
|
||||
existing_instance = json.dumps({"description_html": page.description_html}, cls=DjangoJSONEncoder)
|
||||
existing_instance = json.dumps({"description_html": old_description_html}, cls=DjangoJSONEncoder)
|
||||
|
||||
# Use serializer for validation and update
|
||||
serializer = PageBinaryUpdateSerializer(page, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
# Capture the page transaction
|
||||
if request.data.get("description_html"):
|
||||
page_transaction.delay(
|
||||
new_description_html=request.data.get("description_html", "<p></p>"),
|
||||
old_description_html=page.description_html,
|
||||
old_description_html=old_description_html,
|
||||
page_id=page_id,
|
||||
)
|
||||
|
||||
# Update the page using serializer
|
||||
updated_page = serializer.save()
|
||||
|
||||
# Run background tasks
|
||||
page_version.delay(
|
||||
page_id=updated_page.id,
|
||||
track_page_version.delay(
|
||||
page_id=page_id,
|
||||
existing_instance=existing_instance,
|
||||
user_id=request.user.id,
|
||||
)
|
||||
|
||||
@@ -226,21 +226,36 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
if workspace_role in [5] and int(request.data.get("role", project_member.role)) in [15, 20]:
|
||||
return Response(
|
||||
{"error": "You cannot add a user with role higher than the workspace role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if "role" in request.data:
|
||||
# Only Admins can modify roles
|
||||
if requested_project_member.role < ROLE.ADMIN.value and not is_workspace_admin:
|
||||
return Response(
|
||||
{"error": "You do not have permission to update roles"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
if (
|
||||
"role" in request.data
|
||||
and int(request.data.get("role", project_member.role)) > requested_project_member.role
|
||||
and not is_workspace_admin
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot update a role that is higher than your own role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Cannot modify a member whose role is equal to or higher than your own
|
||||
if project_member.role >= requested_project_member.role and not is_workspace_admin:
|
||||
return Response(
|
||||
{"error": "You cannot update the role of a member with a role equal to or higher than your own"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
new_role = int(request.data.get("role"))
|
||||
|
||||
# Cannot assign a role equal to or higher than your own
|
||||
if new_role >= requested_project_member.role and not is_workspace_admin:
|
||||
return Response(
|
||||
{"error": "You cannot assign a role equal to or higher than your own"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Cannot assign a role higher than the target's workspace role
|
||||
if workspace_role in [5] and new_role in [15, 20]:
|
||||
return Response(
|
||||
{"error": "You cannot add a user with role higher than the workspace role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True)
|
||||
|
||||
|
||||
@@ -3,29 +3,33 @@
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
import requests
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.core.validators import validate_email
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from zxcvbn import zxcvbn
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Profile, User, WorkspaceMemberInvite, FileAsset
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES
|
||||
from plane.bgtasks.user_activation_email_task import user_activation_email
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import FileAsset, Profile, User, WorkspaceMemberInvite
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.settings.storage import S3Storage
|
||||
|
||||
from .error import AUTHENTICATION_ERROR_CODES, AuthenticationException
|
||||
|
||||
|
||||
class Adapter:
|
||||
@@ -37,6 +41,7 @@ class Adapter:
|
||||
self.callback = callback
|
||||
self.token_data = None
|
||||
self.user_data = None
|
||||
self.logger = logging.getLogger("plane.authentication")
|
||||
|
||||
def get_user_token(self, data, headers=None):
|
||||
raise NotImplementedError
|
||||
@@ -59,6 +64,7 @@ class Adapter:
|
||||
def sanitize_email(self, email):
|
||||
# Check if email is present
|
||||
if not email:
|
||||
self.logger.error("Email is not present")
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
|
||||
error_message="INVALID_EMAIL",
|
||||
@@ -72,6 +78,7 @@ class Adapter:
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError:
|
||||
self.logger.warning(f"Email is not valid: {email}")
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
|
||||
error_message="INVALID_EMAIL",
|
||||
@@ -84,6 +91,7 @@ class Adapter:
|
||||
"""Validate password strength"""
|
||||
results = zxcvbn(self.code)
|
||||
if results["score"] < 3:
|
||||
self.logger.warning("Password is not strong enough")
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
|
||||
error_message="PASSWORD_TOO_WEAK",
|
||||
@@ -101,6 +109,7 @@ class Adapter:
|
||||
|
||||
# Check if sign up is disabled and invite is present or not
|
||||
if ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter(email=email).exists():
|
||||
self.logger.warning("Sign up is disabled and invite is not present")
|
||||
# Raise exception
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
|
||||
|
||||
@@ -4,20 +4,21 @@
|
||||
|
||||
# Python imports
|
||||
import requests
|
||||
from django.db import DatabaseError, IntegrityError
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db import DatabaseError, IntegrityError
|
||||
|
||||
from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Account
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
from .base import Adapter
|
||||
from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
class OauthAdapter(Adapter):
|
||||
@@ -78,6 +79,7 @@ class OauthAdapter(Adapter):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException:
|
||||
self.logger.warning("Error getting user token")
|
||||
code = self.authentication_error_code()
|
||||
raise AuthenticationException(error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code))
|
||||
|
||||
@@ -88,6 +90,12 @@ class OauthAdapter(Adapter):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException:
|
||||
self.logger.warning(
|
||||
"Error getting user response",
|
||||
extra={
|
||||
"headers": headers,
|
||||
},
|
||||
)
|
||||
code = self.authentication_error_code()
|
||||
raise AuthenticationException(error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code))
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ import os
|
||||
|
||||
# Module imports
|
||||
from plane.authentication.adapter.credential import CredentialAdapter
|
||||
from plane.db.models import User
|
||||
from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.db.models import User
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
|
||||
|
||||
@@ -24,14 +24,12 @@ class EmailProvider(CredentialAdapter):
|
||||
self.code = code
|
||||
self.is_signup = is_signup
|
||||
|
||||
(ENABLE_EMAIL_PASSWORD,) = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "ENABLE_EMAIL_PASSWORD",
|
||||
"default": os.environ.get("ENABLE_EMAIL_PASSWORD"),
|
||||
}
|
||||
]
|
||||
)
|
||||
(ENABLE_EMAIL_PASSWORD,) = get_configuration_value([
|
||||
{
|
||||
"key": "ENABLE_EMAIL_PASSWORD",
|
||||
"default": os.environ.get("ENABLE_EMAIL_PASSWORD"),
|
||||
}
|
||||
])
|
||||
|
||||
if ENABLE_EMAIL_PASSWORD == "0":
|
||||
raise AuthenticationException(
|
||||
@@ -43,29 +41,29 @@ class EmailProvider(CredentialAdapter):
|
||||
if self.is_signup:
|
||||
# Check if the user already exists
|
||||
if User.objects.filter(email=self.key).exists():
|
||||
self.logger.warning("User already exists")
|
||||
raise AuthenticationException(
|
||||
error_message="USER_ALREADY_EXIST",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
|
||||
)
|
||||
|
||||
super().set_user_data(
|
||||
{
|
||||
"email": self.key,
|
||||
"user": {
|
||||
"avatar": "",
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"provider_id": "",
|
||||
"is_password_autoset": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
super().set_user_data({
|
||||
"email": self.key,
|
||||
"user": {
|
||||
"avatar": "",
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"provider_id": "",
|
||||
"is_password_autoset": False,
|
||||
},
|
||||
})
|
||||
return
|
||||
else:
|
||||
user = User.objects.filter(email=self.key).first()
|
||||
|
||||
# User does not exists
|
||||
if not user:
|
||||
self.logger.warning("User does not exist")
|
||||
raise AuthenticationException(
|
||||
error_message="USER_DOES_NOT_EXIST",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
|
||||
@@ -74,6 +72,7 @@ class EmailProvider(CredentialAdapter):
|
||||
|
||||
# Check user password
|
||||
if not user.check_password(self.code):
|
||||
self.logger.warning("Authentication failed - invalid credentials")
|
||||
raise AuthenticationException(
|
||||
error_message=(
|
||||
"AUTHENTICATION_FAILED_SIGN_UP" if self.is_signup else "AUTHENTICATION_FAILED_SIGN_IN"
|
||||
@@ -84,16 +83,14 @@ class EmailProvider(CredentialAdapter):
|
||||
payload={"email": self.key},
|
||||
)
|
||||
|
||||
super().set_user_data(
|
||||
{
|
||||
"email": self.key,
|
||||
"user": {
|
||||
"avatar": "",
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"provider_id": "",
|
||||
"is_password_autoset": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
super().set_user_data({
|
||||
"email": self.key,
|
||||
"user": {
|
||||
"avatar": "",
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"provider_id": "",
|
||||
"is_password_autoset": False,
|
||||
},
|
||||
})
|
||||
return
|
||||
|
||||
@@ -10,13 +10,14 @@ from urllib.parse import urlencode
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from plane.authentication.adapter.oauth import OauthAdapter
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
|
||||
|
||||
class GitHubOAuthProvider(OauthAdapter):
|
||||
@@ -30,22 +31,20 @@ class GitHubOAuthProvider(OauthAdapter):
|
||||
organization_scope = "read:org"
|
||||
|
||||
def __init__(self, request, code=None, state=None, callback=None):
|
||||
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "GITHUB_CLIENT_ID",
|
||||
"default": os.environ.get("GITHUB_CLIENT_ID"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_CLIENT_SECRET",
|
||||
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_ORGANIZATION_ID",
|
||||
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
|
||||
},
|
||||
]
|
||||
)
|
||||
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value([
|
||||
{
|
||||
"key": "GITHUB_CLIENT_ID",
|
||||
"default": os.environ.get("GITHUB_CLIENT_ID"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_CLIENT_SECRET",
|
||||
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_ORGANIZATION_ID",
|
||||
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
|
||||
},
|
||||
])
|
||||
|
||||
if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET):
|
||||
raise AuthenticationException(
|
||||
@@ -90,32 +89,46 @@ class GitHubOAuthProvider(OauthAdapter):
|
||||
"redirect_uri": self.redirect_uri,
|
||||
}
|
||||
token_response = self.get_user_token(data=data, headers={"Accept": "application/json"})
|
||||
super().set_token_data(
|
||||
{
|
||||
"access_token": token_response.get("access_token"),
|
||||
"refresh_token": token_response.get("refresh_token", None),
|
||||
"access_token_expired_at": (
|
||||
datetime.fromtimestamp(token_response.get("expires_in"), tz=pytz.utc)
|
||||
if token_response.get("expires_in")
|
||||
else None
|
||||
),
|
||||
"refresh_token_expired_at": (
|
||||
datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc)
|
||||
if token_response.get("refresh_token_expired_at")
|
||||
else None
|
||||
),
|
||||
"id_token": token_response.get("id_token", ""),
|
||||
}
|
||||
)
|
||||
super().set_token_data({
|
||||
"access_token": token_response.get("access_token"),
|
||||
"refresh_token": token_response.get("refresh_token", None),
|
||||
"access_token_expired_at": (
|
||||
datetime.fromtimestamp(token_response.get("expires_in"), tz=pytz.utc)
|
||||
if token_response.get("expires_in")
|
||||
else None
|
||||
),
|
||||
"refresh_token_expired_at": (
|
||||
datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc)
|
||||
if token_response.get("refresh_token_expired_at")
|
||||
else None
|
||||
),
|
||||
"id_token": token_response.get("id_token", ""),
|
||||
})
|
||||
|
||||
def __get_email(self, headers):
|
||||
try:
|
||||
# Github does not provide email in user response
|
||||
emails_url = "https://api.github.com/user/emails"
|
||||
emails_response = requests.get(emails_url, headers=headers).json()
|
||||
# Ensure the response is a list before iterating
|
||||
if not isinstance(emails_response, list):
|
||||
self.logger.error("Unexpected response format from GitHub emails API")
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
|
||||
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
email = next((email["email"] for email in emails_response if email["primary"]), None)
|
||||
if not email:
|
||||
self.logger.error("No primary email found for user")
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
|
||||
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
return email
|
||||
except requests.RequestException:
|
||||
self.logger.warning(
|
||||
"Error getting email from GitHub",
|
||||
)
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
|
||||
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
|
||||
@@ -138,22 +151,33 @@ class GitHubOAuthProvider(OauthAdapter):
|
||||
|
||||
if self.organization_id:
|
||||
if not self.is_user_in_organization(user_info_response.get("login")):
|
||||
self.logger.warning(
|
||||
"User is not in organization",
|
||||
extra={
|
||||
"organization_id": self.organization_id,
|
||||
"user_login": user_info_response.get("login"),
|
||||
},
|
||||
)
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_USER_NOT_IN_ORG"],
|
||||
error_message="GITHUB_USER_NOT_IN_ORG",
|
||||
)
|
||||
|
||||
email = self.__get_email(headers=headers)
|
||||
super().set_user_data(
|
||||
{
|
||||
self.logger.debug(
|
||||
"Email found",
|
||||
extra={
|
||||
"email": email,
|
||||
"user": {
|
||||
"provider_id": user_info_response.get("id"),
|
||||
"email": email,
|
||||
"avatar": user_info_response.get("avatar_url"),
|
||||
"first_name": user_info_response.get("name"),
|
||||
"last_name": user_info_response.get("family_name"),
|
||||
"is_password_autoset": True,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
super().set_user_data({
|
||||
"email": email,
|
||||
"user": {
|
||||
"provider_id": user_info_response.get("id"),
|
||||
"email": email,
|
||||
"avatar": user_info_response.get("avatar_url"),
|
||||
"first_name": user_info_response.get("name"),
|
||||
"last_name": user_info_response.get("family_name"),
|
||||
"is_password_autoset": True,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,37 +5,69 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Page, PageVersion
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
PAGE_VERSION_TASK_TIMEOUT = 600
|
||||
|
||||
@shared_task
|
||||
def page_version(page_id, existing_instance, user_id):
|
||||
def track_page_version(page_id, existing_instance, user_id):
|
||||
try:
|
||||
# Get the page
|
||||
page = Page.objects.get(id=page_id)
|
||||
|
||||
# Get the current instance
|
||||
current_instance = json.loads(existing_instance) if existing_instance is not None else {}
|
||||
sub_pages = {}
|
||||
|
||||
|
||||
# Create a version if description_html is updated
|
||||
if current_instance.get("description_html") != page.description_html:
|
||||
# Create a new page version
|
||||
PageVersion.objects.create(
|
||||
page_id=page_id,
|
||||
workspace_id=page.workspace_id,
|
||||
description_html=page.description_html,
|
||||
description_binary=page.description_binary,
|
||||
owned_by_id=user_id,
|
||||
last_saved_at=page.updated_at,
|
||||
description_json=page.description_json,
|
||||
description_stripped=page.description_stripped,
|
||||
)
|
||||
# Fetch the latest page version
|
||||
page_version = PageVersion.objects.filter(page_id=page_id).order_by("-last_saved_at").first()
|
||||
|
||||
# Get the latest page version if it exists and is owned by the user
|
||||
if (
|
||||
page_version
|
||||
and str(page_version.owned_by_id) == str(user_id)
|
||||
and (timezone.now() - page_version.last_saved_at).total_seconds() <= PAGE_VERSION_TASK_TIMEOUT
|
||||
):
|
||||
page_version.description_html = page.description_html
|
||||
page_version.description_binary = page.description_binary
|
||||
page_version.description_json = page.description
|
||||
page_version.description_stripped = page.description_stripped
|
||||
page_version.sub_pages_data = sub_pages
|
||||
page_version.save(
|
||||
update_fields=[
|
||||
"description_html",
|
||||
"description_binary",
|
||||
"description_json",
|
||||
"description_stripped",
|
||||
"sub_pages_data",
|
||||
"updated_at"
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Create a new page version
|
||||
PageVersion.objects.create(
|
||||
page_id=page_id,
|
||||
workspace_id=page.workspace_id,
|
||||
description_json=page.description,
|
||||
description_html=page.description_html,
|
||||
description_binary=page.description_binary,
|
||||
description_stripped=page.description_stripped,
|
||||
owned_by_id=user_id,
|
||||
last_saved_at=timezone.now(),
|
||||
sub_pages_data=sub_pages,
|
||||
)
|
||||
# If page versions are greater than 20 delete the oldest one
|
||||
if PageVersion.objects.filter(page_id=page_id).count() > 20:
|
||||
# Delete the old page version
|
||||
|
||||
@@ -37,6 +37,7 @@ def project_invitation(email, project_id, token, current_site, invitor):
|
||||
"first_name": user.first_name,
|
||||
"project_name": project.name,
|
||||
"invitation_url": abs_url,
|
||||
"current_site": current_site,
|
||||
}
|
||||
|
||||
html_content = render_to_string("emails/invitations/project_invitation.html", context)
|
||||
|
||||
@@ -52,6 +52,7 @@ from plane.db.models import (
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.ip_address import validate_url
|
||||
from plane.settings.mongo import MongoConnection
|
||||
|
||||
|
||||
@@ -325,6 +326,13 @@ def webhook_send_task(
|
||||
return
|
||||
|
||||
try:
|
||||
# Re-validate the webhook URL at send time to prevent DNS-rebinding attacks
|
||||
validate_url(
|
||||
webhook.url,
|
||||
allowed_ips=settings.WEBHOOK_ALLOWED_IPS,
|
||||
allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS,
|
||||
)
|
||||
|
||||
# Send the webhook event
|
||||
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from bs4 import BeautifulSoup
|
||||
from urllib.parse import urlparse, urljoin
|
||||
import base64
|
||||
import ipaddress
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, Tuple
|
||||
from typing import Optional
|
||||
from plane.db.models import IssueLink
|
||||
from plane.utils.exception_logger import log_exception
|
||||
@@ -66,6 +66,52 @@ def validate_url_ip(url: str) -> None:
|
||||
MAX_REDIRECTS = 5
|
||||
|
||||
|
||||
def safe_get(
|
||||
url: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 1,
|
||||
) -> Tuple[requests.Response, str]:
|
||||
"""
|
||||
Perform a GET request that validates every redirect hop against private IPs.
|
||||
Prevents SSRF by ensuring no redirect lands on a private/internal address.
|
||||
|
||||
Args:
|
||||
url: The URL to fetch
|
||||
headers: Optional request headers
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
A tuple of (final Response object, final URL after redirects)
|
||||
|
||||
Raises:
|
||||
ValueError: If any URL in the redirect chain points to a private IP
|
||||
requests.RequestException: On network errors
|
||||
RuntimeError: If max redirects exceeded
|
||||
"""
|
||||
validate_url_ip(url)
|
||||
|
||||
current_url = url
|
||||
response = requests.get(
|
||||
current_url, headers=headers, timeout=timeout, allow_redirects=False
|
||||
)
|
||||
|
||||
redirect_count = 0
|
||||
while response.is_redirect:
|
||||
if redirect_count >= MAX_REDIRECTS:
|
||||
raise RuntimeError(f"Too many redirects for URL: {url}")
|
||||
redirect_url = response.headers.get("Location")
|
||||
if not redirect_url:
|
||||
break
|
||||
current_url = urljoin(current_url, redirect_url)
|
||||
validate_url_ip(current_url)
|
||||
redirect_count += 1
|
||||
response = requests.get(
|
||||
current_url, headers=headers, timeout=timeout, allow_redirects=False
|
||||
)
|
||||
|
||||
return response, current_url
|
||||
|
||||
|
||||
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Crawls a URL to extract the title and favicon.
|
||||
@@ -86,26 +132,8 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
title = None
|
||||
final_url = url
|
||||
|
||||
validate_url_ip(final_url)
|
||||
|
||||
try:
|
||||
# Manually follow redirects to validate each URL before requesting
|
||||
redirect_count = 0
|
||||
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
|
||||
|
||||
while response.is_redirect and redirect_count < MAX_REDIRECTS:
|
||||
redirect_url = response.headers.get("Location")
|
||||
if not redirect_url:
|
||||
break
|
||||
# Resolve relative redirects against current URL
|
||||
final_url = urljoin(final_url, redirect_url)
|
||||
# Validate the redirect target BEFORE making the request
|
||||
validate_url_ip(final_url)
|
||||
redirect_count += 1
|
||||
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
|
||||
|
||||
if redirect_count >= MAX_REDIRECTS:
|
||||
logger.warning(f"Too many redirects for URL: {url}")
|
||||
response, final_url = safe_get(url, headers=headers)
|
||||
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
title_tag = soup.find("title")
|
||||
@@ -113,8 +141,10 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
|
||||
except (ValueError, RuntimeError) as e:
|
||||
logger.warning(f"URL validation failed: {str(e)}")
|
||||
|
||||
# Fetch and encode favicon using final URL (after redirects)
|
||||
# Fetch and encode favicon using final URL (after redirects) for correct relative href resolution
|
||||
favicon_base64 = fetch_and_encode_favicon(headers, soup, final_url)
|
||||
|
||||
# Prepare result
|
||||
@@ -204,9 +234,7 @@ def fetch_and_encode_favicon(
|
||||
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
|
||||
}
|
||||
|
||||
validate_url_ip(favicon_url)
|
||||
|
||||
response = requests.get(favicon_url, headers=headers, timeout=1)
|
||||
response, _ = safe_get(favicon_url, headers=headers)
|
||||
|
||||
# Get content type
|
||||
content_type = response.headers.get("content-type", "image/x-icon")
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.28 on 2026-02-26 14:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0120_issueview_archived_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='estimate',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('categories', 'Categories'), ('points', 'Points')], default='categories', max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -11,10 +11,13 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
||||
# Module import
|
||||
from plane.utils.path_validator import sanitize_filename
|
||||
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
def get_upload_path(instance, filename):
|
||||
filename = sanitize_filename(filename) or uuid4().hex
|
||||
if instance.workspace_id is not None:
|
||||
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
|
||||
return f"user-{uuid4().hex}-{filename}"
|
||||
|
||||
@@ -3,18 +3,22 @@
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Django imports
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
# Module imports
|
||||
from .project import ProjectBaseModel
|
||||
|
||||
class EstimateType(models.TextChoices):
|
||||
CATEGORIES = "categories", "Categories"
|
||||
POINTS = "points", "Points"
|
||||
|
||||
|
||||
class Estimate(ProjectBaseModel):
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(verbose_name="Estimate Description", blank=True)
|
||||
type = models.CharField(max_length=255, default="categories")
|
||||
type = models.CharField(max_length=255, choices=EstimateType.choices, default=EstimateType.CATEGORIES)
|
||||
last_used = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -17,6 +17,7 @@ from django import apps
|
||||
|
||||
# Module imports
|
||||
from plane.utils.html_processor import strip_tags
|
||||
from plane.utils.path_validator import sanitize_filename
|
||||
from plane.db.mixins import SoftDeletionManager
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from .project import ProjectBaseModel
|
||||
@@ -376,6 +377,7 @@ class IssueLink(ProjectBaseModel):
|
||||
|
||||
|
||||
def get_upload_path(instance, filename):
|
||||
filename = sanitize_filename(filename) or uuid4().hex
|
||||
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
|
||||
|
||||
|
||||
|
||||
@@ -45,7 +45,8 @@ class InstanceConfigurationEndpoint(BaseAPIView):
|
||||
|
||||
bulk_configurations = []
|
||||
for configuration in configurations:
|
||||
value = request.data.get(configuration.key, configuration.value)
|
||||
raw_value = request.data.get(configuration.key, configuration.value)
|
||||
value = "" if raw_value is None else str(raw_value).strip()
|
||||
if configuration.is_encrypted:
|
||||
configuration.value = encrypt_data(value)
|
||||
else:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user