Compare commits
114
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
449cac059c | ||
|
|
3a78ba56c7 | ||
|
|
a25635824e | ||
|
|
9f77ea5ebb | ||
|
|
e71a8f5dbb | ||
|
|
41b03bb142 | ||
|
|
fd613dc738 | ||
|
|
039d582fbb | ||
|
|
4ca6d6c7b8 | ||
|
|
208f35964b | ||
|
|
50a7b47b31 | ||
|
|
65d6a94b0a | ||
|
|
7fd8e3364c | ||
|
|
1dabc632bf | ||
|
|
761c999e0c | ||
|
|
4225bc59de | ||
|
|
4c1bdd1d62 | ||
|
|
ff21e53f5a | ||
|
|
9491bdbe46 | ||
|
|
a62fe8a781 | ||
|
|
db1c5b9513 | ||
|
|
a40e064448 | ||
|
|
32fb88ab24 | ||
|
|
03a2be84b7 | ||
|
|
c62930ebcf | ||
|
|
f1d567accc | ||
|
|
62b2d1b207 | ||
|
|
da41f14a05 | ||
|
|
aea66f53f4 | ||
|
|
45b4fc8932 | ||
|
|
a8a16c8ba0 | ||
|
|
ac11c3ef79 | ||
|
|
13db2f883f | ||
|
|
bbf14fba31 | ||
|
|
db3c8f27dc | ||
|
|
39325d28a6 | ||
|
|
c21d2c6fb3 | ||
|
|
e6b9d4c9ba | ||
|
|
6023e8cfc8 | ||
|
|
77c4b9c774 | ||
|
|
8a2579ce9b | ||
|
|
7c2fc2dd7f | ||
|
|
d1db13c3a7 | ||
|
|
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 | ||
|
|
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:[email protected]
|
||||
about: Write to us if you'd like dedicated support using Plane
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "daily"
|
||||
@@ -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 }}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: i18n sync check
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- "preview"
|
||||
types:
|
||||
- "opened"
|
||||
- "synchronize"
|
||||
- "reopened"
|
||||
- "ready_for_review"
|
||||
paths:
|
||||
- "packages/i18n/**"
|
||||
- ".github/workflows/i18n-sync-check.yml"
|
||||
push:
|
||||
branches:
|
||||
- "preview"
|
||||
paths:
|
||||
- "packages/i18n/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-check:
|
||||
name: check:sync
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
if: github.event_name == 'push' || github.event.pull_request.draft == false
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
filter: blob:none
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22.18.0"
|
||||
|
||||
- name: Enable Corepack and pnpm
|
||||
run: corepack enable pnpm
|
||||
|
||||
- name: Run sync check
|
||||
run: pnpm dlx tsx packages/i18n/scripts/sync-check.ts --ci
|
||||
@@ -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,7 +110,7 @@ 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 }}
|
||||
@@ -146,7 +144,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') }}
|
||||
@@ -187,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') }}
|
||||
@@ -195,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 }}
|
||||
|
||||
@@ -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://[email protected]/$TARGET_REPO.git"
|
||||
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
|
||||
@@ -110,3 +110,7 @@ build/
|
||||
.react-router/
|
||||
temp/
|
||||
scripts/
|
||||
!packages/i18n/scripts/
|
||||
|
||||
# i18n auto-generated types (regenerated on every build)
|
||||
packages/i18n/src/types/keys.generated.ts
|
||||
|
||||
+11
-7
@@ -34,16 +34,20 @@
|
||||
"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
|
||||
}]
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"destructuredArrayIgnorePattern": "^_",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,3 +22,15 @@
|
||||
- **State Management**: MobX stores in `packages/shared-state`, reactive patterns
|
||||
- **Testing**: All features require unit tests, use existing test framework per package
|
||||
- **Components**: Build in `@plane/ui` with Storybook for isolated development
|
||||
|
||||
## Backend tests (Docker)
|
||||
|
||||
The Django/pytest suite for `apps/api` runs in an isolated stack defined by `docker-compose-test.yml` at the repo root.
|
||||
|
||||
Prereq (once): `./setup.sh` — generates `apps/api/.env` from `.env.example`.
|
||||
|
||||
- Full suite: `docker compose -f docker-compose-test.yml up --build --abort-on-container-exit --exit-code-from api-tests`
|
||||
- Subset: `docker compose -f docker-compose-test.yml run --rm api-tests pytest -m unit`
|
||||
- Teardown: `docker compose -f docker-compose-test.yml down -v`
|
||||
|
||||
See `apps/api/tests/RUNNING_TESTS.md` for the full walkthrough and troubleshooting; see `apps/api/tests/TESTING_GUIDE.md` for test conventions and fixtures.
|
||||
|
||||
+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
|
||||
+1
-1
@@ -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).
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<p align="center">
|
||||
<a href="https://plane.so/"><b>Website</b></a> •
|
||||
<a href="https://forum.plane.so"><b>Forum</b></a> •
|
||||
<a href="https://twitter.com/planepowers"><b>Twitter</b></a> •
|
||||
<a href="https://x.com/planepowers"><b>X</b></a> •
|
||||
<a href="https://docs.plane.so/"><b>Documentation</b></a>
|
||||
</p>
|
||||
|
||||
@@ -26,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
|
||||
|
||||
@@ -129,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!
|
||||
|
||||
@@ -145,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.8.12
|
||||
RUN pnpm add -g turbo@2.9.4
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -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,8 +98,7 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">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="flex grow items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
|
||||
@@ -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="flex grow items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="flex size-11 items-center justify-center rounded-lg bg-layer-1">
|
||||
<MessageSquare className="size-5 p-0.5 text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grow">
|
||||
<div className="text-13 leading-5 font-medium text-primary">Chat with us</div>
|
||||
<div className="text-11 leading-5 font-regular text-tertiary">
|
||||
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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -88,7 +88,7 @@ export function HydrateFallback() {
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
export function ErrorBoundary({ error: _error }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<div>
|
||||
<p>Something went wrong.</p>
|
||||
|
||||
@@ -11,7 +11,7 @@ http {
|
||||
|
||||
set_real_ip_from 0.0.0.0/0;
|
||||
real_ip_recursive on;
|
||||
real_ip_header X-Forward-For;
|
||||
real_ip_header X-Forwarded-For;
|
||||
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
|
||||
|
||||
access_log /dev/stdout;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"description": "Admin UI for Plane",
|
||||
"license": "AGPL-3.0",
|
||||
@@ -49,7 +49,6 @@
|
||||
"uuid": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "catalog:",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@react-router/dev": "catalog:",
|
||||
@@ -57,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,
|
||||
|
||||
@@ -59,8 +59,10 @@ class CycleCreateSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
project_id = self.initial_data.get("project_id") or (
|
||||
self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None
|
||||
project_id = (
|
||||
self.context.get("project_id")
|
||||
or self.initial_data.get("project_id")
|
||||
or (self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None)
|
||||
)
|
||||
|
||||
if not project_id:
|
||||
|
||||
@@ -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,
|
||||
@@ -68,7 +69,7 @@ class IssueSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at"]
|
||||
read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at", "completed_at"]
|
||||
exclude = ["description_json", "description_stripped"]
|
||||
|
||||
def validate(self, data):
|
||||
@@ -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.
|
||||
@@ -663,6 +850,7 @@ class IssueExpandSerializer(BaseSerializer):
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"completed_at",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -114,13 +114,20 @@ class ProjectCreateSerializer(BaseSerializer):
|
||||
if project_identifier is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_identifier):
|
||||
raise serializers.ValidationError("Project identifier cannot contain special characters.")
|
||||
|
||||
if data.get("project_lead", None) is not None:
|
||||
# Check if the project lead is a member of the workspace
|
||||
if not WorkspaceMember.objects.filter(
|
||||
project_lead = data.get("project_lead")
|
||||
if (
|
||||
project_lead
|
||||
and not WorkspaceMember.objects.filter(
|
||||
workspace_id=self.context["workspace_id"],
|
||||
member_id=data.get("project_lead"),
|
||||
).exists():
|
||||
raise serializers.ValidationError("Project lead should be a user in the workspace")
|
||||
member=project_lead,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
# Field-shaped error so DRF surfaces it under the specific key
|
||||
# rather than as non_field_errors. Also requires the membership
|
||||
# to be active so that revoked / removed members can't slip
|
||||
# through and trigger the FK error downstream.
|
||||
raise serializers.ValidationError({"project_lead": "The provided user is not a member of this workspace."})
|
||||
|
||||
if data.get("default_assignee", None) is not None:
|
||||
# Check if the default assignee is a member of the workspace
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -29,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")
|
||||
|
||||
@@ -305,7 +305,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
|
||||
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
|
||||
):
|
||||
serializer = CycleCreateSerializer(data=request.data, context={"request": request})
|
||||
serializer = CycleCreateSerializer(
|
||||
data=request.data, context={"request": request, "project_id": project_id}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
@@ -516,7 +518,9 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True, context={"request": request})
|
||||
serializer = CycleUpdateSerializer(
|
||||
cycle, data=request.data, partial=True, context={"request": request, "project_id": project_id}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_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,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db import IntegrityError, transaction
|
||||
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
|
||||
@@ -23,7 +23,6 @@ from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
Intake,
|
||||
ProjectUserProperty,
|
||||
Module,
|
||||
Project,
|
||||
DeployBoard,
|
||||
@@ -39,6 +38,7 @@ from plane.db.models import (
|
||||
ProjectPage,
|
||||
)
|
||||
from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.host import base_host
|
||||
from plane.api.serializers import (
|
||||
@@ -224,48 +224,72 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||
serializer = ProjectCreateSerializer(data={**request.data}, context={"workspace_id": workspace.id})
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
with transaction.atomic():
|
||||
serializer.save()
|
||||
|
||||
# Add the user as Administrator to the project
|
||||
_ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, role=20)
|
||||
# Add the creator as Administrator of the project.
|
||||
_ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, role=20)
|
||||
|
||||
if serializer.instance.project_lead is not None and str(serializer.instance.project_lead) != str(
|
||||
request.user.id
|
||||
):
|
||||
ProjectMember.objects.create(
|
||||
project_id=serializer.instance.id,
|
||||
member_id=serializer.instance.project_lead,
|
||||
role=20,
|
||||
# If a different project_lead was provided, add them as
|
||||
# Administrator too. Use project_lead_id (the FK column)
|
||||
# rather than project_lead (the related descriptor, which
|
||||
# would resolve to a User instance and break UUID coercion
|
||||
# downstream in ProjectMember.objects.create).
|
||||
if (
|
||||
serializer.instance.project_lead_id is not None
|
||||
and serializer.instance.project_lead_id != request.user.id
|
||||
):
|
||||
ProjectMember.objects.create(
|
||||
project_id=serializer.instance.id,
|
||||
member_id=serializer.instance.project_lead_id,
|
||||
role=20,
|
||||
)
|
||||
|
||||
State.objects.bulk_create(
|
||||
[
|
||||
State(
|
||||
name=state["name"],
|
||||
color=state["color"],
|
||||
project=serializer.instance,
|
||||
sequence=state["sequence"],
|
||||
workspace=serializer.instance.workspace,
|
||||
group=state["group"],
|
||||
default=state.get("default", False),
|
||||
created_by=request.user,
|
||||
)
|
||||
for state in DEFAULT_STATES
|
||||
]
|
||||
)
|
||||
|
||||
State.objects.bulk_create(
|
||||
[
|
||||
State(
|
||||
name=state["name"],
|
||||
color=state["color"],
|
||||
project=serializer.instance,
|
||||
sequence=state["sequence"],
|
||||
workspace=serializer.instance.workspace,
|
||||
group=state["group"],
|
||||
default=state.get("default", False),
|
||||
created_by=request.user,
|
||||
project = self.get_queryset().filter(pk=serializer.instance.id).first()
|
||||
|
||||
# Defer the activity-log task until the surrounding
|
||||
# transaction commits, so it never fires on a rolled-back
|
||||
# creation.
|
||||
# robust=True so broker / dispatch failures are logged
|
||||
# internally by Django and don't surface as 500 after a
|
||||
# successful commit (the inverse of the rollback path
|
||||
# covered by test_model_activity_not_called_on_rollback).
|
||||
# A nested function (rather than functools.partial) is
|
||||
# used here because Django's robust on_commit logging
|
||||
# path reads ``func.__qualname__`` to format the error
|
||||
# message; ``partial`` objects don't have that dunder
|
||||
# by default and the workaround is brittle when the
|
||||
# wrapped callable is a mock. The closure captures
|
||||
# the locals at construction time and they are never
|
||||
# rebound, so late-binding is not a hazard here.
|
||||
def _dispatch_model_activity():
|
||||
model_activity.delay(
|
||||
model_name="project",
|
||||
model_id=str(project.id),
|
||||
requested_data=request.data,
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
for state in DEFAULT_STATES
|
||||
]
|
||||
)
|
||||
|
||||
project = self.get_queryset().filter(pk=serializer.instance.id).first()
|
||||
|
||||
# Model activity
|
||||
model_activity.delay(
|
||||
model_name="project",
|
||||
model_id=str(project.id),
|
||||
requested_data=request.data,
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
transaction.on_commit(_dispatch_model_activity, robust=True)
|
||||
|
||||
serializer = ProjectSerializer(project)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
@@ -276,6 +300,17 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
# Any other IntegrityError is unexpected: log it the same way
|
||||
# the catch-all `except Exception` below would and return the
|
||||
# same generic 500 so the client gets a uniform error shape.
|
||||
# `raise` here would not fall through to a sibling except
|
||||
# clause — it would exit the try/except entirely and bypass
|
||||
# both the logging and the JSON response.
|
||||
log_exception(e)
|
||||
return Response(
|
||||
{"error": "An unexpected error occurred"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
except Workspace.DoesNotExist:
|
||||
return Response({"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND)
|
||||
except ValidationError:
|
||||
@@ -283,6 +318,16 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except Exception as e:
|
||||
# Unexpected server-side failure: log the traceback and return a
|
||||
# generic 500 so the client can distinguish it from a 4xx caused
|
||||
# by bad input. Returning 400 here was the anti-pattern that
|
||||
# masked the original ghost-create bug.
|
||||
log_exception(e)
|
||||
return Response(
|
||||
{"error": "An unexpected error occurred"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
class ProjectDetailAPIEndpoint(BaseAPIView):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -110,6 +110,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"completed_at",
|
||||
]
|
||||
|
||||
def to_representation(self, instance):
|
||||
|
||||
@@ -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):
|
||||
@@ -45,7 +44,7 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
serializer = APITokenReadSerializer(api_tokens, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
api_tokens = APIToken.objects.get(user=request.user, pk=pk)
|
||||
api_tokens = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
|
||||
serializer = APITokenReadSerializer(api_tokens)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -55,34 +54,9 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def patch(self, request: Request, pk: str) -> Response:
|
||||
api_token = APIToken.objects.get(user=request.user, pk=pk)
|
||||
api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
|
||||
serializer = APITokenSerializer(api_token, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
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"),
|
||||
|
||||
@@ -113,7 +113,7 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
estimate = Estimate.objects.get(pk=estimate_id)
|
||||
estimate = Estimate.objects.get(pk=estimate_id, workspace__slug=slug, project_id=project_id)
|
||||
|
||||
if request.data.get("estimate"):
|
||||
estimate.name = request.data.get("estimate").get("name", estimate.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))
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ class IssueListEndpoint(BaseAPIView):
|
||||
# Apply legacy filters
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = queryset.filter(**filters)
|
||||
issue_queryset = issue_queryset.filter(state__deleted_at__isnull=True)
|
||||
|
||||
# Add select_related, prefetch_related if fields or expand is not None
|
||||
if self.fields or self.expand:
|
||||
@@ -157,7 +158,7 @@ class IssueListEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
if self.fields or self.expand:
|
||||
issues = IssueSerializer(queryset, many=True, fields=self.fields, expand=self.expand).data
|
||||
issues = IssueSerializer(issue_queryset, many=True, fields=self.fields, expand=self.expand).data
|
||||
else:
|
||||
issues = issue_queryset.values(
|
||||
"id",
|
||||
@@ -1118,7 +1119,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
|
||||
|
||||
@@ -332,7 +332,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
project = Project.objects.get(pk=pk)
|
||||
project = Project.objects.get(pk=pk, workspace__slug=slug)
|
||||
intake_view = request.data.get("inbox_view", project.intake_view)
|
||||
current_instance = json.dumps(ProjectSerializer(project).data, cls=DjangoJSONEncoder)
|
||||
if project.archived_at:
|
||||
|
||||
@@ -206,11 +206,15 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
project_member = ProjectMember.objects.get(pk=pk, workspace__slug=slug, project_id=project_id, is_active=True)
|
||||
|
||||
# Fetch the workspace role of the project member
|
||||
workspace_role = WorkspaceMember.objects.get(
|
||||
# Fetch the target's workspace role (used to cap the new project role)
|
||||
target_workspace_role = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=project_member.member, is_active=True
|
||||
).role
|
||||
is_workspace_admin = workspace_role == ROLE.ADMIN.value
|
||||
# Fetch the requester's workspace role to decide if they may bypass project-role checks
|
||||
requester_workspace_role = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=request.user, is_active=True
|
||||
).role
|
||||
is_workspace_admin = requester_workspace_role == ROLE.ADMIN.value
|
||||
|
||||
# Check if the user is not editing their own role if they are not an admin
|
||||
if request.user.id == project_member.member_id and not is_workspace_admin:
|
||||
@@ -226,21 +230,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 target_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)
|
||||
|
||||
|
||||
@@ -279,11 +279,16 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView):
|
||||
|
||||
class WorkspaceUserProfileEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, user_id):
|
||||
user_data = User.objects.get(pk=user_id)
|
||||
|
||||
requesting_workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=request.user, is_active=True
|
||||
)
|
||||
|
||||
# Verify the target user is also an active member of this workspace
|
||||
# before exposing their profile data.
|
||||
target_workspace_member = WorkspaceMember.objects.select_related("member").get(
|
||||
workspace__slug=slug, member_id=user_id, is_active=True
|
||||
)
|
||||
user_data = target_workspace_member.member
|
||||
projects = []
|
||||
if requesting_workspace_member.role >= 15:
|
||||
projects = (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,8 +326,23 @@ 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)
|
||||
# allow_redirects=False prevents SSRF via 3xx hops to internal addresses
|
||||
# bypassing the validate_url() check above (GHSA-mq87-52pf-hm3h).
|
||||
response = requests.post(
|
||||
webhook.url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=30,
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
# Log the webhook request
|
||||
save_webhook_log(
|
||||
|
||||
@@ -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
|
||||
@@ -36,15 +36,15 @@ def validate_url_ip(url: str) -> None:
|
||||
ValueError: If the URL points to a private/internal IP
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
|
||||
if not hostname:
|
||||
raise ValueError("Invalid URL: No hostname found")
|
||||
|
||||
# Only allow HTTP and HTTPS to prevent file://, gopher://, etc.
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("Invalid URL scheme. Only HTTP and HTTPS are allowed")
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise ValueError("Invalid URL: No hostname found")
|
||||
|
||||
# Resolve hostname to IP addresses — this catches domain names that
|
||||
# point to internal IPs (e.g. attacker.com -> 169.254.169.254)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -8,7 +8,7 @@ import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import Celery
|
||||
from pythonjsonlogger.jsonlogger import JsonFormatter
|
||||
from pythonjsonlogger.json import JsonFormatter
|
||||
from celery.signals import after_setup_logger, after_setup_task_logger
|
||||
from celery.schedules import crontab
|
||||
|
||||
|
||||
@@ -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,12 +17,12 @@ from django import apps
|
||||
|
||||
# Module imports
|
||||
from plane.utils.html_processor import strip_tags
|
||||
from plane.db.mixins import SoftDeletionManager
|
||||
from plane.utils.path_validator import sanitize_filename
|
||||
from plane.db.mixins import SoftDeletionManager, ChangeTrackerMixin
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from .project import ProjectBaseModel
|
||||
from plane.utils.uuid import convert_uuid_to_integer
|
||||
from .description import Description
|
||||
from plane.db.mixins import ChangeTrackerMixin
|
||||
from .state import StateGroup
|
||||
|
||||
|
||||
@@ -101,7 +101,9 @@ class IssueManager(SoftDeletionManager):
|
||||
)
|
||||
|
||||
|
||||
class Issue(ProjectBaseModel):
|
||||
class Issue(ChangeTrackerMixin, ProjectBaseModel):
|
||||
TRACKED_FIELDS = ["state_id"]
|
||||
|
||||
PRIORITY_CHOICES = (
|
||||
("urgent", "Urgent"),
|
||||
("high", "High"),
|
||||
@@ -176,30 +178,8 @@ class Issue(ProjectBaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.state is None:
|
||||
try:
|
||||
from plane.db.models import State
|
||||
|
||||
default_state = State.objects.filter(
|
||||
~models.Q(is_triage=True), project=self.project, default=True
|
||||
).first()
|
||||
if default_state is None:
|
||||
random_state = State.objects.filter(~models.Q(is_triage=True), project=self.project).first()
|
||||
self.state = random_state
|
||||
else:
|
||||
self.state = default_state
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
from plane.db.models import State
|
||||
|
||||
if self.state.group == "completed":
|
||||
self.completed_at = timezone.now()
|
||||
else:
|
||||
self.completed_at = None
|
||||
except ImportError:
|
||||
pass
|
||||
self._ensure_default_state()
|
||||
kwargs = self._sync_completed_at(kwargs)
|
||||
|
||||
if self._state.adding:
|
||||
with transaction.atomic():
|
||||
@@ -245,6 +225,35 @@ class Issue(ProjectBaseModel):
|
||||
"""Return name of the issue"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
def _ensure_default_state(self):
|
||||
"""Assign a default state when none is set."""
|
||||
if self.state is not None:
|
||||
return
|
||||
try:
|
||||
from plane.db.models import State
|
||||
|
||||
default_state = State.objects.filter(~models.Q(is_triage=True), project=self.project, default=True).first()
|
||||
self.state = default_state or State.objects.filter(~models.Q(is_triage=True), project=self.project).first()
|
||||
except ImportError as e:
|
||||
log_exception(e)
|
||||
|
||||
def _sync_completed_at(self, kwargs):
|
||||
"""Update completed_at when state changes. Returns kwargs."""
|
||||
if not self.state:
|
||||
return kwargs
|
||||
if not self._state.adding and not self.has_changed("state_id"):
|
||||
return kwargs
|
||||
|
||||
if self.state.group == StateGroup.COMPLETED.value:
|
||||
self.completed_at = timezone.now()
|
||||
else:
|
||||
self.completed_at = None
|
||||
|
||||
update_fields = kwargs.get("update_fields")
|
||||
if update_fields is not None:
|
||||
kwargs["update_fields"] = list(set(update_fields) | {"completed_at"})
|
||||
return kwargs
|
||||
|
||||
|
||||
class IssueBlocker(ProjectBaseModel):
|
||||
block = models.ForeignKey(Issue, related_name="blocker_issues", on_delete=models.CASCADE)
|
||||
@@ -376,6 +385,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:
|
||||
|
||||
@@ -63,8 +63,6 @@ class InstanceEndpoint(BaseAPIView):
|
||||
POSTHOG_HOST,
|
||||
UNSPLASH_ACCESS_KEY,
|
||||
LLM_API_KEY,
|
||||
IS_INTERCOM_ENABLED,
|
||||
INTERCOM_APP_ID,
|
||||
) = get_configuration_value(
|
||||
[
|
||||
{
|
||||
@@ -124,15 +122,6 @@ class InstanceEndpoint(BaseAPIView):
|
||||
"key": "LLM_API_KEY",
|
||||
"default": os.environ.get("LLM_API_KEY", ""),
|
||||
},
|
||||
# Intercom settings
|
||||
{
|
||||
"key": "IS_INTERCOM_ENABLED",
|
||||
"default": os.environ.get("IS_INTERCOM_ENABLED", "1"),
|
||||
},
|
||||
{
|
||||
"key": "INTERCOM_APP_ID",
|
||||
"default": os.environ.get("INTERCOM_APP_ID", ""),
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
@@ -169,10 +158,6 @@ class InstanceEndpoint(BaseAPIView):
|
||||
# is smtp configured
|
||||
data["is_smtp_configured"] = bool(EMAIL_HOST)
|
||||
|
||||
# Intercom settings
|
||||
data["is_intercom_enabled"] = IS_INTERCOM_ENABLED == "1"
|
||||
data["intercom_app_id"] = INTERCOM_APP_ID
|
||||
|
||||
# Base URL
|
||||
data["admin_base_url"] = settings.ADMIN_BASE_URL
|
||||
data["space_base_url"] = settings.SPACE_BASE_URL
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"""Global Settings"""
|
||||
|
||||
# Python imports
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urljoin
|
||||
@@ -32,6 +34,44 @@ DEBUG = int(os.environ.get("DEBUG", "0"))
|
||||
# Self-hosted mode
|
||||
IS_SELF_MANAGED = True
|
||||
|
||||
# Webhook IP allowlist — comma-separated IPs or CIDR ranges that are allowed as
|
||||
# webhook targets even if they resolve to private networks.
|
||||
# Example: "10.0.0.0/8,192.168.1.0/24,172.16.0.5"
|
||||
_webhook_allowed_ips_raw = os.environ.get("WEBHOOK_ALLOWED_IPS", "")
|
||||
WEBHOOK_ALLOWED_IPS = []
|
||||
_logger = logging.getLogger("plane")
|
||||
for _cidr in _webhook_allowed_ips_raw.split(","):
|
||||
_cidr = _cidr.strip()
|
||||
if not _cidr:
|
||||
continue
|
||||
try:
|
||||
WEBHOOK_ALLOWED_IPS.append(ipaddress.ip_network(_cidr, strict=False))
|
||||
except ValueError:
|
||||
_logger.warning("WEBHOOK_ALLOWED_IPS: skipping invalid entry %r", _cidr)
|
||||
|
||||
# Webhook hostname allowlist — comma-separated hostnames that bypass the
|
||||
# private-IP SSRF check. Useful for trusted internal services whose IPs are
|
||||
# dynamic in containerised deployments (e.g. docker-compose service DNS,
|
||||
# kubernetes service hostnames).
|
||||
# Example: "silo,silo.namespace.svc.cluster.local,internal-api.lan"
|
||||
_webhook_allowed_hosts_raw = os.environ.get("WEBHOOK_ALLOWED_HOSTS", "")
|
||||
WEBHOOK_ALLOWED_HOSTS = [
|
||||
_host.strip().rstrip(".").lower()
|
||||
for _host in _webhook_allowed_hosts_raw.split(",")
|
||||
if _host.strip()
|
||||
]
|
||||
|
||||
# Webhook disallowed domains — comma-separated hostnames. Webhooks targeting
|
||||
# these domains or any of their subdomains are rejected (the request host is
|
||||
# always appended at validation time as a loop-back guard). Empty by default
|
||||
# for self-hosted deployments; set to e.g. "plane.so" to block specific domains.
|
||||
_webhook_disallowed_domains_raw = os.environ.get("WEBHOOK_DISALLOWED_DOMAINS", "")
|
||||
WEBHOOK_DISALLOWED_DOMAINS = [
|
||||
_d.strip().rstrip(".").lower()
|
||||
for _d in _webhook_disallowed_domains_raw.split(",")
|
||||
if _d.strip()
|
||||
]
|
||||
|
||||
# Allowed Hosts
|
||||
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
|
||||
|
||||
@@ -224,7 +264,6 @@ MEDIA_URL = "/media/"
|
||||
# Internationalization
|
||||
LANGUAGE_CODE = "en-us"
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
|
||||
# Timezones
|
||||
USE_TZ = True
|
||||
|
||||
@@ -46,7 +46,7 @@ LOGGING = {
|
||||
"style": "{",
|
||||
},
|
||||
"json": {
|
||||
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
|
||||
"()": "pythonjsonlogger.json.JsonFormatter",
|
||||
"fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s",
|
||||
},
|
||||
},
|
||||
@@ -80,6 +80,11 @@ LOGGING = {
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.authentication": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.migrations": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
|
||||
@@ -34,7 +34,7 @@ LOGGING = {
|
||||
"formatters": {
|
||||
"verbose": {"format": "%(asctime)s [%(process)d] %(levelname)s %(name)s: %(message)s"},
|
||||
"json": {
|
||||
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
|
||||
"()": "pythonjsonlogger.json.JsonFormatter",
|
||||
"fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s",
|
||||
},
|
||||
},
|
||||
@@ -90,6 +90,11 @@ LOGGING = {
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.authentication": {
|
||||
"level": "DEBUG" if DEBUG else "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.migrations": {
|
||||
"level": "DEBUG" if DEBUG else "INFO",
|
||||
"handlers": ["console"],
|
||||
|
||||
@@ -18,6 +18,7 @@ from rest_framework.response import Response
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.db.models import DeployBoard, FileAsset
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.path_validator import sanitize_filename
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
@@ -73,7 +74,7 @@ class EntityAssetEndpoint(BaseAPIView):
|
||||
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Get the asset
|
||||
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", "")
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 946 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -19,6 +19,7 @@ def project(db, workspace, create_user):
|
||||
identifier="TP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
cycle_view=True,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project,
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import Project, ProjectMember, State, User, WorkspaceMember
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_workspace_member(db, workspace):
|
||||
"""Create another user that is a member of the workspace, distinct from the creator."""
|
||||
unique_id = uuid4().hex[:8]
|
||||
other = User.objects.create(
|
||||
email=f"other-{unique_id}@plane.so",
|
||||
username=f"other_user_{unique_id}",
|
||||
first_name="Other",
|
||||
last_name="User",
|
||||
)
|
||||
other.set_password("test-password")
|
||||
other.save()
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=other, role=20)
|
||||
return other
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outsider_user(db):
|
||||
"""Create a user that is NOT a member of any workspace under test."""
|
||||
unique_id = uuid4().hex[:8]
|
||||
outsider = User.objects.create(
|
||||
email=f"outsider-{unique_id}@plane.so",
|
||||
username=f"outsider_{unique_id}",
|
||||
first_name="Out",
|
||||
last_name="Sider",
|
||||
)
|
||||
outsider.set_password("test-password")
|
||||
outsider.save()
|
||||
return outsider
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestProjectListCreateAPIEndpoint:
|
||||
"""Contract tests for POST /api/v1/workspaces/{slug}/projects/."""
|
||||
|
||||
def get_url(self, workspace_slug):
|
||||
return f"/api/v1/workspaces/{workspace_slug}/projects/"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_with_lead_as_creator(self, api_key_client, workspace, create_user):
|
||||
"""Regression for the ghost-create bug.
|
||||
|
||||
When project_lead points to the creator's own user_id, the endpoint
|
||||
must return 201 and create a fully-populated project (single
|
||||
ProjectMember as admin, default workflow states).
|
||||
|
||||
Before the fix, the endpoint returned 400 "Please provide valid detail"
|
||||
but had already persisted the Project row without states or members,
|
||||
leaving an unusable orphan.
|
||||
"""
|
||||
url = self.get_url(workspace.slug)
|
||||
payload = {
|
||||
"name": "Self Lead Project",
|
||||
"identifier": "SL",
|
||||
"project_lead": str(create_user.id),
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
|
||||
|
||||
# Look up the project we just created instead of relying on
|
||||
# ordering-sensitive Project.objects.first().
|
||||
project = Project.objects.get(id=response.data["id"])
|
||||
# Creator is registered as admin (single membership; lead == creator
|
||||
# should not produce a duplicate row).
|
||||
assert ProjectMember.objects.filter(project=project, member=create_user, role=20).count() == 1
|
||||
# Default workflow states must be created.
|
||||
assert State.objects.filter(project=project).count() == 5
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_with_lead_as_other_user(
|
||||
self, api_key_client, workspace, create_user, other_workspace_member
|
||||
):
|
||||
"""When project_lead is a different workspace member, both creator
|
||||
and lead become admins of the project."""
|
||||
url = self.get_url(workspace.slug)
|
||||
payload = {
|
||||
"name": "Other Lead Project",
|
||||
"identifier": "OL",
|
||||
"project_lead": str(other_workspace_member.id),
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
|
||||
project = Project.objects.get(id=response.data["id"])
|
||||
|
||||
# Both creator and other_workspace_member are admins.
|
||||
assert ProjectMember.objects.filter(project=project, member=create_user, role=20).exists()
|
||||
assert ProjectMember.objects.filter(project=project, member=other_workspace_member, role=20).exists()
|
||||
assert State.objects.filter(project=project).count() == 5
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_without_lead(self, api_key_client, workspace, create_user):
|
||||
"""Baseline regression: omitting project_lead must succeed and the
|
||||
creator becomes the sole admin."""
|
||||
url = self.get_url(workspace.slug)
|
||||
payload = {
|
||||
"name": "Basic Project",
|
||||
"identifier": "BP",
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
|
||||
project = Project.objects.get(id=response.data["id"])
|
||||
assert ProjectMember.objects.filter(project=project, member=create_user, role=20).count() == 1
|
||||
assert State.objects.filter(project=project).count() == 5
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_project_with_lead_not_in_workspace_returns_400(self, api_key_client, workspace, outsider_user):
|
||||
"""When project_lead refers to a user that is NOT a member of the
|
||||
target workspace, the endpoint must reject the request with a 400
|
||||
carrying a field-shaped error and must not persist the Project."""
|
||||
url = self.get_url(workspace.slug)
|
||||
payload = {
|
||||
"name": "Outsider Lead Project",
|
||||
"identifier": "OUT",
|
||||
"project_lead": str(outsider_user.id),
|
||||
}
|
||||
|
||||
response = api_key_client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST, f"Got {response.status_code}: {response.data!r}"
|
||||
assert "project_lead" in response.data, (
|
||||
f"Expected field-shaped error under 'project_lead', got {response.data!r}"
|
||||
)
|
||||
# No project should have been persisted.
|
||||
assert Project.objects.count() == 0
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_model_activity_not_called_on_rollback(self, api_key_client, workspace, create_user):
|
||||
"""If anything inside the transaction.atomic() block raises, the
|
||||
whole creation must roll back (no Project, no ProjectMember, no
|
||||
State) and the deferred model_activity.delay() task must not fire,
|
||||
because it is registered with transaction.on_commit().
|
||||
|
||||
Force the failure inside State.objects.bulk_create — past the point
|
||||
where the original ghost-create bug would have committed a partial
|
||||
Project — and verify the response is 500 with no side effects.
|
||||
"""
|
||||
url = self.get_url(workspace.slug)
|
||||
payload = {
|
||||
"name": "Rollback Probe",
|
||||
"identifier": "RB",
|
||||
"project_lead": str(create_user.id),
|
||||
}
|
||||
|
||||
forced_error = RuntimeError("forced failure for rollback test")
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"plane.api.views.project.State.objects.bulk_create",
|
||||
side_effect=forced_error,
|
||||
),
|
||||
mock.patch("plane.api.views.project.model_activity") as mocked_activity,
|
||||
):
|
||||
response = api_key_client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR, (
|
||||
f"Got {response.status_code}: {response.data!r}"
|
||||
)
|
||||
# Transaction must have rolled back: no Project, no ProjectMember,
|
||||
# no State persisted.
|
||||
assert Project.objects.count() == 0
|
||||
assert ProjectMember.objects.count() == 0
|
||||
assert State.objects.count() == 0
|
||||
# And the deferred Celery task must not have been dispatched —
|
||||
# transaction.on_commit() callbacks only fire on a successful commit.
|
||||
mocked_activity.delay.assert_not_called()
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_response_still_201_when_broker_dispatch_fails(self, api_key_client, workspace, create_user):
|
||||
"""If model_activity.delay raises *after* the atomic block has
|
||||
committed (e.g., the Celery broker is down), the project, member
|
||||
rows and states are already persisted — the response must remain
|
||||
201 and the failure must be absorbed by Django's robust=True
|
||||
on_commit handling, not surface as a 500.
|
||||
|
||||
Uses ``transaction=True`` so the surrounding test transaction is
|
||||
actually committed and the ``on_commit`` callback fires (the
|
||||
default ``django_db`` wrapper would suppress it via rollback)."""
|
||||
url = self.get_url(workspace.slug)
|
||||
payload = {
|
||||
"name": "Broker Down",
|
||||
"identifier": "BD",
|
||||
"project_lead": str(create_user.id),
|
||||
}
|
||||
|
||||
with mock.patch("plane.api.views.project.model_activity") as mocked_activity:
|
||||
mocked_activity.delay.side_effect = RuntimeError("broker unavailable")
|
||||
response = api_key_client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
|
||||
# Project and its scaffolding are persisted (commit happened
|
||||
# before the on_commit callback fired).
|
||||
project = Project.objects.get(id=response.data["id"])
|
||||
assert ProjectMember.objects.filter(project=project).count() == 1
|
||||
assert State.objects.filter(project=project).count() == 5
|
||||
# The dispatch was attempted but its failure was swallowed by
|
||||
# transaction.on_commit(robust=True).
|
||||
mocked_activity.delay.assert_called_once()
|
||||
@@ -302,9 +302,10 @@ class TestMagicSignIn:
|
||||
user_data = json.loads(ri.get("[email protected]"))
|
||||
token = user_data["token"]
|
||||
|
||||
# Use Django client to test the redirect flow without following redirects
|
||||
# Use Django client to test the redirect flow without following redirects.
|
||||
# next_path must start with "/" per validate_next_path (otherwise it's discarded).
|
||||
url = reverse("magic-sign-in")
|
||||
next_path = "workspaces"
|
||||
next_path = "/workspaces"
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "[email protected]", "code": token, "next_path": next_path},
|
||||
@@ -315,8 +316,8 @@ class TestMagicSignIn:
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# Check that the redirect URL contains the next_path
|
||||
assert next_path in response.url
|
||||
# Check that the redirect URL contains the next_path (URL-encoded, leading slash → %2F)
|
||||
assert "workspaces" in response.url
|
||||
|
||||
# The user should now be authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
@@ -78,6 +78,7 @@ class TestCopyS3Objects:
|
||||
mock_sync.return_value = {
|
||||
"description": "test description",
|
||||
"description_binary": base64.b64encode(b"test binary").decode(),
|
||||
"description_json": {"type": "doc", "content": []},
|
||||
}
|
||||
|
||||
# Call the actual function (not .delay())
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import ipaddress
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from plane.bgtasks.work_item_link_task import safe_get, validate_url_ip
|
||||
from plane.utils.ip_address import validate_url
|
||||
|
||||
|
||||
def _make_response(status_code=200, headers=None, is_redirect=False, content=b""):
|
||||
"""Create a mock requests.Response."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.is_redirect = is_redirect
|
||||
resp.headers = headers or {}
|
||||
resp.content = content
|
||||
return resp
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestValidateUrlIp:
|
||||
"""Test validate_url_ip blocks private/internal IPs."""
|
||||
|
||||
def test_rejects_private_ip(self):
|
||||
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url_ip("http://example.com")
|
||||
|
||||
def test_rejects_loopback(self):
|
||||
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("127.0.0.1", 0))]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url_ip("http://example.com")
|
||||
|
||||
def test_rejects_non_http_scheme(self):
|
||||
with pytest.raises(ValueError, match="Only HTTP and HTTPS"):
|
||||
validate_url_ip("file:///etc/passwd")
|
||||
|
||||
def test_allows_public_ip(self):
|
||||
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("93.184.216.34", 0))]
|
||||
validate_url_ip("https://example.com") # Should not raise
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestValidateUrlAllowlist:
|
||||
"""Test validate_url allowlist permits specific private IPs."""
|
||||
|
||||
def test_allowlist_permits_private_ip(self):
|
||||
allowed = [ipaddress.ip_network("192.168.1.0/24")]
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("192.168.1.50", 0))]
|
||||
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
|
||||
|
||||
def test_allowlist_does_not_permit_other_private_ip(self):
|
||||
allowed = [ipaddress.ip_network("192.168.1.0/24")]
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url("http://example.com", allowed_ips=allowed)
|
||||
|
||||
def test_allowlist_permits_loopback_when_explicitly_allowed(self):
|
||||
allowed = [ipaddress.ip_network("127.0.0.0/8")]
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("127.0.0.1", 0))]
|
||||
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
|
||||
|
||||
def test_allowlist_permits_matching_ipv4_with_mixed_version_networks(self):
|
||||
allowed = [
|
||||
ipaddress.ip_network("2001:db8::/32"),
|
||||
ipaddress.ip_network("192.168.1.0/24"),
|
||||
]
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("192.168.1.50", 0))]
|
||||
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
|
||||
|
||||
def test_allowlist_blocks_non_matching_ipv4_with_mixed_version_networks(self):
|
||||
allowed = [
|
||||
ipaddress.ip_network("2001:db8::/32"),
|
||||
ipaddress.ip_network("192.168.1.0/24"),
|
||||
]
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url("http://example.com", allowed_ips=allowed)
|
||||
|
||||
def test_allowed_hosts_bypasses_private_ip_check(self):
|
||||
"""Hostnames in WEBHOOK_ALLOWED_HOSTS skip IP-based blocking — used for
|
||||
trusted internal services (e.g. Silo) whose IPs are dynamic in
|
||||
containerised deployments."""
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("172.18.0.5", 0))]
|
||||
validate_url("http://silo:3000/hook", allowed_hosts=["silo"]) # Should not raise
|
||||
|
||||
def test_allowed_hosts_matches_case_insensitively(self):
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
|
||||
validate_url(
|
||||
"http://Silo.Namespace.Svc.Cluster.Local/x",
|
||||
allowed_hosts=["silo.namespace.svc.cluster.local"],
|
||||
) # Should not raise
|
||||
|
||||
def test_allowed_hosts_skips_dns_lookup(self):
|
||||
"""When the hostname is explicitly trusted we shouldn't even resolve it —
|
||||
protects against operators who allowlist a name that isn't resolvable
|
||||
from the API container."""
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
validate_url("http://silo/hook", allowed_hosts=["silo"])
|
||||
mock_dns.assert_not_called()
|
||||
|
||||
def test_allowed_hosts_requires_exact_match(self):
|
||||
"""Subdomains of an allowed host must NOT bypass — a hostile
|
||||
``attacker.silo.internal`` should still be blocked when only
|
||||
``silo.internal`` is allowed."""
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url(
|
||||
"http://attacker.silo.internal/x",
|
||||
allowed_hosts=["silo.internal"],
|
||||
)
|
||||
|
||||
def test_allowed_hosts_empty_does_not_bypass(self):
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url("http://silo/hook", allowed_hosts=[])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSafeGet:
|
||||
"""Test safe_get follows redirects safely and blocks SSRF."""
|
||||
|
||||
@patch("plane.bgtasks.work_item_link_task.requests.get")
|
||||
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
|
||||
def test_returns_response_for_non_redirect(self, mock_validate, mock_get):
|
||||
final_resp = _make_response(status_code=200, content=b"OK")
|
||||
mock_get.return_value = final_resp
|
||||
|
||||
response, final_url = safe_get("https://example.com")
|
||||
|
||||
assert response is final_resp
|
||||
assert final_url == "https://example.com"
|
||||
mock_validate.assert_called_once_with("https://example.com")
|
||||
|
||||
@patch("plane.bgtasks.work_item_link_task.requests.get")
|
||||
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
|
||||
def test_follows_redirect_and_validates_each_hop(self, mock_validate, mock_get):
|
||||
redirect_resp = _make_response(
|
||||
status_code=301,
|
||||
is_redirect=True,
|
||||
headers={"Location": "https://other.com/page"},
|
||||
)
|
||||
final_resp = _make_response(status_code=200, content=b"OK")
|
||||
mock_get.side_effect = [redirect_resp, final_resp]
|
||||
|
||||
response, final_url = safe_get("https://example.com")
|
||||
|
||||
assert response is final_resp
|
||||
assert final_url == "https://other.com/page"
|
||||
# Should validate both the initial URL and the redirect target
|
||||
assert mock_validate.call_count == 2
|
||||
mock_validate.assert_any_call("https://example.com")
|
||||
mock_validate.assert_any_call("https://other.com/page")
|
||||
|
||||
@patch("plane.bgtasks.work_item_link_task.requests.get")
|
||||
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
|
||||
def test_blocks_redirect_to_private_ip(self, mock_validate, mock_get):
|
||||
redirect_resp = _make_response(
|
||||
status_code=302,
|
||||
is_redirect=True,
|
||||
headers={"Location": "http://192.168.1.1:8080"},
|
||||
)
|
||||
mock_get.return_value = redirect_resp
|
||||
# First call (initial URL) succeeds, second call (redirect target) fails
|
||||
mock_validate.side_effect = [None, ValueError("Access to private/internal networks is not allowed")]
|
||||
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
safe_get("https://evil.com/redirect")
|
||||
|
||||
@patch("plane.bgtasks.work_item_link_task.requests.get")
|
||||
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
|
||||
def test_raises_on_too_many_redirects(self, mock_validate, mock_get):
|
||||
redirect_resp = _make_response(
|
||||
status_code=302,
|
||||
is_redirect=True,
|
||||
headers={"Location": "https://example.com/loop"},
|
||||
)
|
||||
mock_get.return_value = redirect_resp
|
||||
|
||||
with pytest.raises(RuntimeError, match="Too many redirects"):
|
||||
safe_get("https://example.com/start")
|
||||
|
||||
@patch("plane.bgtasks.work_item_link_task.requests.get")
|
||||
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
|
||||
def test_succeeds_at_exact_max_redirects(self, mock_validate, mock_get):
|
||||
"""After exactly MAX_REDIRECTS hops, if the final response is 200, it should succeed."""
|
||||
redirect_resp = _make_response(
|
||||
status_code=302,
|
||||
is_redirect=True,
|
||||
headers={"Location": "https://example.com/next"},
|
||||
)
|
||||
final_resp = _make_response(status_code=200, content=b"OK")
|
||||
# 5 redirects then a 200
|
||||
mock_get.side_effect = [redirect_resp] * 5 + [final_resp]
|
||||
|
||||
response, final_url = safe_get("https://example.com/start")
|
||||
|
||||
assert response is final_resp
|
||||
assert not response.is_redirect
|
||||
@@ -68,16 +68,20 @@ class TestContainsURL:
|
||||
assert contains_url("www.") is False # Incomplete www - needs at least one char after dot
|
||||
|
||||
def test_contains_url_length_limit_under_1000(self):
|
||||
"""Test contains_url with input under 1000 characters containing URLs"""
|
||||
# Create a string under 1000 characters with a URL
|
||||
text_with_url = "a" * 970 + " https://example.com" # 970 + 1 + 19 = 990 chars
|
||||
"""Test contains_url with input under 1000 characters containing URLs.
|
||||
|
||||
Note: contains_url also truncates each line to 500 chars (ReDoS protection),
|
||||
so URLs must fall within the first 500 chars of their line.
|
||||
"""
|
||||
# Single line under 500 chars with URL at the end
|
||||
text_with_url = "a" * 470 + " https://example.com" # 490 chars total
|
||||
assert len(text_with_url) < 1000
|
||||
assert contains_url(text_with_url) is True
|
||||
|
||||
# Test with exactly 1000 characters
|
||||
text_exact_1000 = "a" * 981 + "https://example.com" # 981 + 19 = 1000 chars
|
||||
assert len(text_exact_1000) == 1000
|
||||
assert contains_url(text_exact_1000) is True
|
||||
# Multi-line input under 1000 chars total; URL on its own short line
|
||||
text_multiline = "a" * 480 + "\nhttps://example.com\n" + "b" * 480
|
||||
assert len(text_multiline) < 1000
|
||||
assert contains_url(text_multiline) is True
|
||||
|
||||
def test_contains_url_length_limit_over_1000(self):
|
||||
"""Test contains_url with input over 1000 characters returns False"""
|
||||
@@ -91,14 +95,17 @@ class TestContainsURL:
|
||||
assert contains_url(long_text_with_url) is False
|
||||
|
||||
def test_contains_url_length_limit_exactly_1000(self):
|
||||
"""Test contains_url with input exactly 1000 characters"""
|
||||
"""Test contains_url with input exactly 1000 characters.
|
||||
|
||||
URLs must fall within the first 500 chars of their line (ReDoS protection).
|
||||
"""
|
||||
# Test with exactly 1000 characters without URL
|
||||
text_no_url = "a" * 1000
|
||||
assert len(text_no_url) == 1000
|
||||
assert contains_url(text_no_url) is False
|
||||
|
||||
# Test with exactly 1000 characters with URL at the end
|
||||
text_with_url = "a" * 981 + "https://example.com" # 981 + 19 = 1000 chars
|
||||
# Multi-line totalling exactly 1000 chars; URL on a short line
|
||||
text_with_url = "a" * 480 + "\nhttps://example.com\n" + "b" * 499 # 480+1+19+1+499 = 1000
|
||||
assert len(text_with_url) == 1000
|
||||
assert contains_url(text_with_url) is True
|
||||
|
||||
@@ -121,8 +128,9 @@ class TestContainsURL:
|
||||
over_limit_text = "a" * 1001 # No URL, but over total limit
|
||||
assert contains_url(over_limit_text) is False
|
||||
|
||||
# Test that under total limit, line processing works normally
|
||||
under_limit_with_url = "a" * 900 + "https://example.com" # 919 chars total
|
||||
# Test that under total limit, line processing works normally.
|
||||
# URL must be within first 500 chars of its line (ReDoS protection).
|
||||
under_limit_with_url = "a" * 400 + "https://example.com" # 419 chars total, fits in 500
|
||||
assert len(under_limit_with_url) < 1000
|
||||
assert contains_url(under_limit_with_url) is True
|
||||
|
||||
|
||||
@@ -22,6 +22,23 @@ from django.utils import timezone
|
||||
# Module imports
|
||||
from plane.db.models import Issue, Project
|
||||
|
||||
VALID_ANALYTICS_FIELDS = [
|
||||
"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"]
|
||||
|
||||
|
||||
def annotate_with_monthly_dimension(queryset, field_name, attribute):
|
||||
# Get the year and the months
|
||||
@@ -34,6 +51,8 @@ def annotate_with_monthly_dimension(queryset, field_name, attribute):
|
||||
|
||||
|
||||
def extract_axis(queryset, x_axis):
|
||||
if x_axis not in VALID_ANALYTICS_FIELDS:
|
||||
raise ValueError(f"Invalid x_axis value: {x_axis}")
|
||||
# Format the dimension when the axis is in date
|
||||
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
|
||||
queryset = annotate_with_monthly_dimension(queryset, x_axis, "dimension")
|
||||
@@ -52,6 +71,13 @@ def sort_data(data, temp_axis):
|
||||
|
||||
|
||||
def build_graph_plot(queryset, x_axis, y_axis, segment=None):
|
||||
if x_axis not in VALID_ANALYTICS_FIELDS:
|
||||
raise ValueError(f"Invalid x_axis value: {x_axis}")
|
||||
if y_axis not in VALID_YAXIS:
|
||||
raise ValueError(f"Invalid y_axis value: {y_axis}")
|
||||
if segment and segment not in VALID_ANALYTICS_FIELDS:
|
||||
raise ValueError(f"Invalid segment value: {segment}")
|
||||
|
||||
# temp x_axis
|
||||
temp_axis = x_axis
|
||||
# Extract the x_axis and queryset
|
||||
|
||||
@@ -232,21 +232,6 @@ unsplash_config_variables = [
|
||||
},
|
||||
]
|
||||
|
||||
intercom_config_variables = [
|
||||
{
|
||||
"key": "IS_INTERCOM_ENABLED",
|
||||
"value": os.environ.get("IS_INTERCOM_ENABLED", "1"),
|
||||
"category": "INTERCOM",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "INTERCOM_APP_ID",
|
||||
"value": os.environ.get("INTERCOM_APP_ID", ""),
|
||||
"category": "INTERCOM",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
]
|
||||
|
||||
core_config_variables = [
|
||||
*authentication_config_variables,
|
||||
*workspace_management_config_variables,
|
||||
@@ -257,5 +242,4 @@ core_config_variables = [
|
||||
*smtp_config_variables,
|
||||
*llm_config_variables,
|
||||
*unsplash_config_variables,
|
||||
*intercom_config_variables,
|
||||
]
|
||||
|
||||
@@ -2,6 +2,63 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def validate_url(url, allowed_ips=None, allowed_hosts=None):
|
||||
"""
|
||||
Validate that a URL doesn't resolve to a private/internal IP address (SSRF protection).
|
||||
|
||||
Args:
|
||||
url: The URL to validate.
|
||||
allowed_ips: Optional list of ipaddress.ip_network objects. IPs falling within
|
||||
these networks are permitted even if they are private/loopback/reserved.
|
||||
Typically sourced from the WEBHOOK_ALLOWED_IPS setting.
|
||||
allowed_hosts: Optional iterable of hostnames that bypass IP-based blocking
|
||||
(exact, case-insensitive match against the URL hostname).
|
||||
Typically sourced from the WEBHOOK_ALLOWED_HOSTS setting and
|
||||
used for trusted internal services (e.g. Silo) whose IPs are
|
||||
dynamic in containerised deployments.
|
||||
|
||||
Raises:
|
||||
ValueError: If the URL is invalid or resolves to a blocked IP.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
|
||||
if not hostname:
|
||||
raise ValueError("Invalid URL: No hostname found")
|
||||
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("Invalid URL scheme. Only HTTP and HTTPS are allowed")
|
||||
|
||||
normalized_host = hostname.rstrip(".").lower()
|
||||
if allowed_hosts and normalized_host in {
|
||||
(h or "").rstrip(".").lower() for h in allowed_hosts if h
|
||||
}:
|
||||
return
|
||||
|
||||
try:
|
||||
addr_info = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror:
|
||||
raise ValueError("Hostname could not be resolved")
|
||||
|
||||
if not addr_info:
|
||||
raise ValueError("No IP addresses found for the hostname")
|
||||
|
||||
for addr in addr_info:
|
||||
ip = ipaddress.ip_address(addr[4][0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
||||
if allowed_ips and any(
|
||||
network.version == ip.version and ip in network for network in allowed_ips
|
||||
):
|
||||
continue
|
||||
raise ValueError("Access to private/internal networks is not allowed")
|
||||
|
||||
|
||||
def get_client_ip(request):
|
||||
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||
if x_forwarded_for:
|
||||
|
||||
@@ -47,6 +47,7 @@ from .parameters import (
|
||||
CYCLE_VIEW_PARAMETER,
|
||||
FIELDS_PARAMETER,
|
||||
EXPAND_PARAMETER,
|
||||
ESTIMATE_ID_PARAMETER,
|
||||
)
|
||||
|
||||
# Responses
|
||||
@@ -126,6 +127,10 @@ from .examples import (
|
||||
STATE_UPDATE_EXAMPLE,
|
||||
INTAKE_ISSUE_CREATE_EXAMPLE,
|
||||
INTAKE_ISSUE_UPDATE_EXAMPLE,
|
||||
ESTIMATE_CREATE_EXAMPLE,
|
||||
ESTIMATE_UPDATE_EXAMPLE,
|
||||
ESTIMATE_POINT_CREATE_EXAMPLE,
|
||||
ESTIMATE_POINT_UPDATE_EXAMPLE,
|
||||
# Response Examples
|
||||
CYCLE_EXAMPLE,
|
||||
TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE,
|
||||
@@ -145,6 +150,8 @@ from .examples import (
|
||||
PROJECT_MEMBER_EXAMPLE,
|
||||
CYCLE_ISSUE_EXAMPLE,
|
||||
STICKY_EXAMPLE,
|
||||
ESTIMATE_EXAMPLE,
|
||||
ESTIMATE_POINT_EXAMPLE,
|
||||
)
|
||||
|
||||
# Helper decorators
|
||||
@@ -157,6 +164,7 @@ from .decorators import (
|
||||
user_docs,
|
||||
cycle_docs,
|
||||
work_item_docs,
|
||||
work_item_relation_docs,
|
||||
label_docs,
|
||||
issue_link_docs,
|
||||
issue_comment_docs,
|
||||
@@ -165,6 +173,8 @@ from .decorators import (
|
||||
module_docs,
|
||||
module_issue_docs,
|
||||
state_docs,
|
||||
estimate_docs,
|
||||
estimate_point_docs,
|
||||
)
|
||||
|
||||
# Schema processing hooks
|
||||
@@ -206,6 +216,7 @@ __all__ = [
|
||||
"CYCLE_VIEW_PARAMETER",
|
||||
"FIELDS_PARAMETER",
|
||||
"EXPAND_PARAMETER",
|
||||
"ESTIMATE_ID_PARAMETER",
|
||||
# Responses
|
||||
"UNAUTHORIZED_RESPONSE",
|
||||
"FORBIDDEN_RESPONSE",
|
||||
@@ -279,6 +290,10 @@ __all__ = [
|
||||
"STATE_UPDATE_EXAMPLE",
|
||||
"INTAKE_ISSUE_CREATE_EXAMPLE",
|
||||
"INTAKE_ISSUE_UPDATE_EXAMPLE",
|
||||
"ESTIMATE_CREATE_EXAMPLE",
|
||||
"ESTIMATE_UPDATE_EXAMPLE",
|
||||
"ESTIMATE_POINT_CREATE_EXAMPLE",
|
||||
"ESTIMATE_POINT_UPDATE_EXAMPLE",
|
||||
# Response Examples
|
||||
"CYCLE_EXAMPLE",
|
||||
"TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE",
|
||||
@@ -298,6 +313,8 @@ __all__ = [
|
||||
"PROJECT_MEMBER_EXAMPLE",
|
||||
"CYCLE_ISSUE_EXAMPLE",
|
||||
"STICKY_EXAMPLE",
|
||||
"ESTIMATE_EXAMPLE",
|
||||
"ESTIMATE_POINT_EXAMPLE",
|
||||
# Decorators
|
||||
"workspace_docs",
|
||||
"project_docs",
|
||||
@@ -307,6 +324,7 @@ __all__ = [
|
||||
"user_docs",
|
||||
"cycle_docs",
|
||||
"work_item_docs",
|
||||
"work_item_relation_docs",
|
||||
"label_docs",
|
||||
"issue_link_docs",
|
||||
"issue_comment_docs",
|
||||
@@ -315,6 +333,8 @@ __all__ = [
|
||||
"module_docs",
|
||||
"module_issue_docs",
|
||||
"state_docs",
|
||||
"estimate_docs",
|
||||
"estimate_point_docs",
|
||||
# Hooks
|
||||
"preprocess_filter_api_v1_paths",
|
||||
"generate_operation_summary",
|
||||
|
||||
@@ -223,6 +223,21 @@ def issue_attachment_docs(**kwargs):
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def work_item_relation_docs(**kwargs):
|
||||
"""Decorator for work item relation endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Work Item Relations"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
|
||||
def module_docs(**kwargs):
|
||||
"""Decorator for module management endpoints"""
|
||||
defaults = {
|
||||
@@ -282,3 +297,29 @@ def sticky_docs(**kwargs):
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
def estimate_docs(**kwargs):
|
||||
"""Decorator for estimate-related endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Estimates"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
def estimate_point_docs(**kwargs):
|
||||
"""Decorator for estimate point-related endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Estimate Points"],
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
@@ -686,6 +686,69 @@ STICKY_EXAMPLE = OpenApiExample(
|
||||
},
|
||||
)
|
||||
|
||||
# Estimate Examples
|
||||
ESTIMATE_EXAMPLE = OpenApiExample(
|
||||
name="Estimate",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Estimate 1",
|
||||
"description": "Estimate 1 description",
|
||||
},
|
||||
description="Example response for an estimate",
|
||||
)
|
||||
|
||||
ESTIMATE_POINT_EXAMPLE = OpenApiExample(
|
||||
name="EstimatePoint",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"estimate": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"key": 1,
|
||||
"value": "1",
|
||||
},
|
||||
description="Example response for an estimate point",
|
||||
)
|
||||
ESTIMATE_CREATE_EXAMPLE = OpenApiExample(
|
||||
name="EstimateCreateSerializer",
|
||||
value={
|
||||
"name": "Estimate 1",
|
||||
"description": "Estimate 1 description",
|
||||
},
|
||||
description="Example request for creating an estimate",
|
||||
)
|
||||
ESTIMATE_UPDATE_EXAMPLE = OpenApiExample(
|
||||
name="EstimateUpdateSerializer",
|
||||
value={
|
||||
"name": "Estimate 1",
|
||||
"description": "Estimate 1 description",
|
||||
},
|
||||
description="Example request for updating an estimate",
|
||||
)
|
||||
|
||||
# Estimate Point Examples
|
||||
ESTIMATE_POINT_CREATE_EXAMPLE = OpenApiExample(
|
||||
name="EstimatePointCreateSerializer",
|
||||
value=[
|
||||
{
|
||||
"value": "1",
|
||||
"description": "Estimate Point 1 description",
|
||||
},
|
||||
{
|
||||
"value": "2",
|
||||
"description": "Estimate Point 2 description",
|
||||
},
|
||||
],
|
||||
description="Example request for creating an estimate point",
|
||||
)
|
||||
ESTIMATE_POINT_UPDATE_EXAMPLE = OpenApiExample(
|
||||
name="EstimatePointUpdateSerializer",
|
||||
value={
|
||||
"value": "1",
|
||||
"description": "Estimate Point 1 description",
|
||||
},
|
||||
description="Example request for updating an estimate point",
|
||||
)
|
||||
|
||||
|
||||
# Sample data for different entity types
|
||||
SAMPLE_ISSUE = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
@@ -801,6 +864,24 @@ SAMPLE_STICKY = {
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
}
|
||||
|
||||
SAMPLE_ESTIMATE = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Estimate 1",
|
||||
"description": "Estimate 1 description",
|
||||
"type": "categories",
|
||||
"last_used": False,
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
}
|
||||
|
||||
SAMPLE_ESTIMATE_POINT = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"estimate": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"key": 1,
|
||||
"value": "1",
|
||||
"description": "Estimate Point 1 description",
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
}
|
||||
|
||||
# Mapping of schema types to sample data
|
||||
SCHEMA_EXAMPLES = {
|
||||
"Issue": SAMPLE_ISSUE,
|
||||
@@ -816,6 +897,8 @@ SCHEMA_EXAMPLES = {
|
||||
"Intake": SAMPLE_INTAKE,
|
||||
"CycleIssue": SAMPLE_CYCLE_ISSUE,
|
||||
"Sticky": SAMPLE_STICKY,
|
||||
"Estimate": SAMPLE_ESTIMATE,
|
||||
"EstimatePoint": SAMPLE_ESTIMATE_POINT,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -495,3 +495,11 @@ EXPAND_PARAMETER = OpenApiParameter(
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
ESTIMATE_ID_PARAMETER = OpenApiParameter(
|
||||
name="estimate_id",
|
||||
description="Estimate ID",
|
||||
required=True,
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
)
|
||||
|
||||
@@ -7,9 +7,50 @@ from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.conf import settings
|
||||
|
||||
# Python imports
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def sanitize_filename(filename):
|
||||
"""
|
||||
Sanitize a filename to prevent path traversal attacks.
|
||||
|
||||
Strips directory components, path traversal sequences, and null bytes
|
||||
from user-supplied filenames used in upload paths and S3 object keys.
|
||||
|
||||
Returns None for empty/missing input so callers can still validate
|
||||
that a filename was provided.
|
||||
"""
|
||||
if not filename or not isinstance(filename, str):
|
||||
return None
|
||||
|
||||
# Strip null bytes
|
||||
filename = filename.replace("\x00", "")
|
||||
|
||||
# Normalize backslashes so os.path.basename handles Windows-style paths on POSIX
|
||||
filename = filename.replace("\\", "/")
|
||||
|
||||
# Take only the basename to remove any directory components
|
||||
filename = os.path.basename(filename)
|
||||
|
||||
# Remove any remaining path traversal sequences
|
||||
filename = filename.replace("..", "")
|
||||
|
||||
# Strip whitespace before removing leading dots so " .env" is caught
|
||||
filename = filename.strip()
|
||||
|
||||
# Remove leading dots (hidden files)
|
||||
filename = filename.lstrip(".")
|
||||
|
||||
# Strip any remaining whitespace
|
||||
filename = filename.strip()
|
||||
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def _contains_suspicious_patterns(path: str) -> bool:
|
||||
"""
|
||||
Check for suspicious patterns that might indicate malicious intent.
|
||||
@@ -49,20 +90,15 @@ def _contains_suspicious_patterns(path: str) -> bool:
|
||||
|
||||
def get_allowed_hosts() -> list[str]:
|
||||
"""Get the allowed hosts from the settings."""
|
||||
base_origin = settings.WEB_URL or settings.APP_BASE_URL
|
||||
|
||||
allowed_hosts = []
|
||||
if base_origin:
|
||||
host = urlparse(base_origin).netloc
|
||||
allowed_hosts.append(host)
|
||||
if settings.ADMIN_BASE_URL:
|
||||
# Get only the host
|
||||
host = urlparse(settings.ADMIN_BASE_URL).netloc
|
||||
allowed_hosts.append(host)
|
||||
if settings.SPACE_BASE_URL:
|
||||
# Get only the host
|
||||
host = urlparse(settings.SPACE_BASE_URL).netloc
|
||||
allowed_hosts.append(host)
|
||||
# Include every configured base URL; WEB_URL and APP_BASE_URL may differ
|
||||
# (e.g. WEB_URL points at the API host, APP_BASE_URL at the web app), and
|
||||
# both need to be allowed for redirects to either origin to pass safety checks.
|
||||
for setting in (settings.WEB_URL, settings.APP_BASE_URL, settings.ADMIN_BASE_URL, settings.SPACE_BASE_URL):
|
||||
if setting:
|
||||
host = urlparse(setting).netloc
|
||||
if host and host not in allowed_hosts:
|
||||
allowed_hosts.append(host)
|
||||
return allowed_hosts
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.28
|
||||
Django==4.2.30
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
@@ -45,15 +45,15 @@ scout-apm==3.1.0
|
||||
# xlsx generation
|
||||
openpyxl==3.1.2
|
||||
# logging
|
||||
python-json-logger==3.3.0
|
||||
python-json-logger==4.0.0
|
||||
# html parser
|
||||
beautifulsoup4==4.12.3
|
||||
# analytics
|
||||
posthog==3.5.0
|
||||
# crypto
|
||||
cryptography==46.0.5
|
||||
cryptography==46.0.7
|
||||
# html validator
|
||||
lxml==6.0.0
|
||||
lxml==6.1.0
|
||||
# s3
|
||||
boto3==1.34.96
|
||||
# password validator
|
||||
@@ -61,7 +61,7 @@ zxcvbn==4.4.28
|
||||
# timezone
|
||||
pytz==2024.1
|
||||
# jwt
|
||||
PyJWT==2.8.0
|
||||
PyJWT==2.12.0
|
||||
# OpenTelemetry
|
||||
opentelemetry-api==1.28.1
|
||||
opentelemetry-sdk==1.28.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-r base.txt
|
||||
# test framework
|
||||
pytest==7.4.0
|
||||
pytest==9.0.3
|
||||
pytest-django==4.5.2
|
||||
pytest-cov==4.1.0
|
||||
pytest-xdist==3.3.1
|
||||
@@ -9,4 +9,4 @@ factory-boy==3.3.0
|
||||
freezegun==1.2.2
|
||||
coverage==7.2.7
|
||||
httpx==0.24.1
|
||||
requests==2.32.4
|
||||
requests==2.33.0
|
||||
@@ -124,7 +124,7 @@
|
||||
<td class="nl2go-responsive-hide" width="20" style=" font-size: 0px; line-height: 1px; " > </td>
|
||||
<td align="left" valign="top" class="r17-i nl2go-default-textstyle" style=" color: #3b3f44; font-family: arial, helvetica, sans-serif; font-size: 16px; line-height: 1.5; text-align: left; " >
|
||||
<div>
|
||||
<p style="margin: 0"> <span style=" font-size: 12px; " >Note: Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our </span ><a href="https://discord.gg/A92xrEGCge" target="_blank" style=" color: #0092ff; text-decoration: underline; " ><span style=" font-size: 12px; " >Discord</span ></a ><span style=" font-size: 12px; " > or </span ><a href="https://github.com/makeplane/plane" target="_blank" style=" color: #0092ff; text-decoration: underline; " ><span style=" font-size: 12px; " >GitHub</span ></a ><span style=" font-size: 12px; " >, and we will use your feedback to improve on our upcoming releases.</span > </p>
|
||||
<p style="margin: 0"> <span style=" font-size: 12px; " >Note: Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our </span ><a href="https://forum.plane.so" target="_blank" style=" color: #0092ff; text-decoration: underline; " ><span style=" font-size: 12px; " >Forum</span ></a ><span style=" font-size: 12px; " > or </span ><a href="https://github.com/makeplane/plane" target="_blank" style=" color: #0092ff; text-decoration: underline; " ><span style=" font-size: 12px; " >GitHub</span ></a ><span style=" font-size: 12px; " >, and we will use your feedback to improve on our upcoming releases.</span > </p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="20" style=" font-size: 0px; line-height: 1px; " > </td>
|
||||
@@ -227,7 +227,7 @@
|
||||
<td height="5" width="8" style=" font-size: 5px; line-height: 5px; " > </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://github.com/makeplane/plane" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/github_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
|
||||
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://github.com/makeplane/plane" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="{{ current_site }}/static/logos/github_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
|
||||
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide" >
|
||||
@@ -244,7 +244,7 @@
|
||||
<td height="5" width="8" style=" font-size: 5px; line-height: 5px; " > </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://discord.gg/A92xrEGCge" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/discord_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
|
||||
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://forum.plane.so" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="{{ current_site }}/static/logos/website_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
|
||||
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide" >
|
||||
@@ -261,7 +261,7 @@
|
||||
<td height="5" width="8" style=" font-size: 5px; line-height: 5px; " > </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://twitter.com/planepowers" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/twitter_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
|
||||
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://x.com/planepowers" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="{{ current_site }}/static/logos/twitter_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
|
||||
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide" >
|
||||
@@ -277,7 +277,7 @@
|
||||
<td height="5" style=" font-size: 5px; line-height: 5px; " > </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/linkedin_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
|
||||
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="{{ current_site }}/static/logos/linkedin_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide" >
|
||||
<td height="5" style=" font-size: 5px; line-height: 5px; " > </td>
|
||||
@@ -346,4 +346,4 @@
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
<td>
|
||||
<div style="font-size: 0.8rem; color: #1c2024">
|
||||
This email was sent to <a href="mailto:{{receiver.email}}" style="color: #3a5bc7; font-weight: 500; text-decoration: none" >{{ receiver.email }}.</a > If you'd rather not receive this kind of email, <a href="{{ issue_url }}" style="color: #3a5bc7; text-decoration: none" >you can unsubscribe to the {{entity_type}}</a > or <a href="{{ user_preference }}" style="color: #3a5bc7; text-decoration: none" >manage your email preferences</a >. <!-- Github | LinkedIn | Twitter -->
|
||||
<div style="margin-top: 60px; float: right"> <a href="https://github.com/makeplane" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://twitter.com/planepowers" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> </div>
|
||||
<div style="margin-top: 60px; float: right"> <a href="https://github.com/makeplane" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://x.com/planepowers" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> </div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
<td class="nl2go-responsive-hide" width="2" style=" font-size: 0px; line-height: 1px; background-color: #efefef; " > </td>
|
||||
<td align="left" valign="top" class="r21-i nl2go-default-textstyle" style=" color: #3b3f44; font-family: georgia, serif; font-size: 16px; word-break: break-word; line-height: 1.5; padding-bottom: 5px; padding-left: 5px; padding-right: 5px; padding-top: 5px; text-align: left; " >
|
||||
<div>
|
||||
<p style="margin: 0"> <span style="font-size: 13px" >Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every </span ><a href="https://discord.com/channels/1031547764020084846/1094927053867995176" title="Plane Support on Discod" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >message</span ></a ><span style="font-size: 13px" >, </span ><a href="http://twitter.com/planepowers" title="@planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >tweet</span ></a ><span style="font-size: 13px" >, and </span ><a href="https://github.com/makeplane/plane/issues" title="Plane's GitHub conversations" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >conversation</span ></a ><span style="font-size: 13px" > and update </span ><a href="https://plane.sh/plane/0b170a1c-0e55-47cb-9307-ea49a05672b5?board=kanban" title="Plane's roadmap" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >our public roadmap</span ></a ><span style="font-size: 13px" >.</span > </p>
|
||||
<p style="margin: 0"> <span style="font-size: 13px" >Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every </span ><a href="https://forum.plane.so" title="Plane Forum" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >message</span ></a ><span style="font-size: 13px" >, </span ><a href="https://x.com/planepowers" title="@planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >tweet</span ></a ><span style="font-size: 13px" >, and </span ><a href="https://github.com/makeplane/plane/issues" title="Plane's GitHub conversations" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >conversation</span ></a ><span style="font-size: 13px" > and update </span ><a href="https://plane.sh/plane/0b170a1c-0e55-47cb-9307-ea49a05672b5?board=kanban" title="Plane's roadmap" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >our public roadmap</span ></a ><span style="font-size: 13px" >.</span > </p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="2" style=" font-size: 0px; line-height: 1px; background-color: #efefef; " > </td>
|
||||
@@ -220,7 +220,7 @@
|
||||
<th width="40" class="r25-c mobshow resp-table" style=" font-weight: normal; " >
|
||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r26-o" style=" table-layout: fixed; width: 100%; " >
|
||||
<tr>
|
||||
<td class="r18-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://twitter.com/planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
|
||||
<td class="r18-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://x.com/planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
|
||||
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > </td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -831,7 +831,7 @@
|
||||
"
|
||||
>
|
||||
<a
|
||||
href="https://twitter.com/planepowers"
|
||||
href="https://x.com/planepowers"
|
||||
target="_blank"
|
||||
style="
|
||||
color: #006399;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user