diff --git a/.codespellrc b/.codespellrc deleted file mode 100644 index ffe730b74..000000000 --- a/.codespellrc +++ /dev/null @@ -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 diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml index ec0376929..277a3bdfa 100644 --- a/.github/ISSUE_TEMPLATE/--bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/--bug-report.yaml @@ -1,7 +1,7 @@ name: Bug report description: Create a bug report to help us improve Plane title: "[bug]: " -labels: [🐛bug] +labels: [🐛bug, plane] assignees: [vihar, pushya22] body: - type: markdown diff --git a/.github/ISSUE_TEMPLATE/--feature-request.yaml b/.github/ISSUE_TEMPLATE/--feature-request.yaml index 390c95aaa..c2bd609c0 100644 --- a/.github/ISSUE_TEMPLATE/--feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/--feature-request.yaml @@ -1,7 +1,7 @@ name: Feature request description: Suggest a feature to improve Plane title: "[feature]: " -labels: [✨feature] +labels: [✨feature, plane] assignees: [vihar, pushya22] body: - type: markdown diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml index 29c267831..301080b75 100644 --- a/.github/ISSUE_TEMPLATE/config.yaml +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -1,6 +1,6 @@ contact_links: - name: Help and support - about: Reach out to us on our Discord server or GitHub discussions. + about: Reach out to us on our Forum or GitHub discussions. - name: Dedicated support url: mailto:support@plane.so about: Write to us if you'd like dedicated support using Plane diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 087a012d4..8ad71e7d6 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -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 diff --git a/.github/workflows/check-version.yml b/.github/workflows/check-version.yml index 855ee359f..e32581f2e 100644 --- a/.github/workflows/check-version.yml +++ b/.github/workflows/check-version.yml @@ -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 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e3aba5cf1..a645c192f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -20,43 +20,20 @@ jobs: 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}}" diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml deleted file mode 100644 index ca87dc934..000000000 --- a/.github/workflows/codespell.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/copyright-check.yml b/.github/workflows/copyright-check.yml new file mode 100644 index 000000000..b406833a8 --- /dev/null +++ b/.github/workflows/copyright-check.yml @@ -0,0 +1,45 @@ +name: Copy Right Check + +on: + workflow_dispatch: + pull_request: + branches: + - "preview" + types: + - "opened" + - "synchronize" + - "ready_for_review" + - "review_requested" + - "reopened" + +jobs: + license-check: + name: Copy Right Check + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.22" + + - name: Install addlicense + run: | + go install github.com/google/addlicense@latest + echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: Check Copyright For Python Files + run: | + set -e + echo "Running copyright check..." + addlicense -check -f COPYRIGHT.txt -ignore "**/migrations/**" $(git ls-files '*.py') + echo "Copyright check passed." + + - name: Check Copyright For TypeScript Files + run: | + set -e + echo "Running copyright check..." + addlicense -check -f COPYRIGHT.txt -ignore "**/*.config.ts" -ignore "**/*.d.ts" $(git ls-files '*.ts' '*.tsx') + echo "Copyright check passed." diff --git a/.github/workflows/feature-deployment.yml b/.github/workflows/feature-deployment.yml index dad3489df..c0740c517 100644 --- a/.github/workflows/feature-deployment.yml +++ b/.github/workflows/feature-deployment.yml @@ -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 }} diff --git a/.github/workflows/pull-request-build-lint-api.yml b/.github/workflows/pull-request-build-lint-api.yml index 11612207b..28a623f8e 100644 --- a/.github/workflows/pull-request-build-lint-api.yml +++ b/.github/workflows/pull-request-build-lint-api.yml @@ -27,11 +27,13 @@ jobs: github.event.pull_request.draft == false && github.event.pull_request.requested_reviewers != null steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12.x" + cache: 'pip' + cache-dependency-path: 'apps/api/requirements.txt' - name: Install Pylint run: python -m pip install ruff - name: Install API Dependencies diff --git a/.github/workflows/pull-request-build-lint-web-apps.yml b/.github/workflows/pull-request-build-lint-web-apps.yml index 7ddaceb79..719f10118 100644 --- a/.github/workflows/pull-request-build-lint-web-apps.yml +++ b/.github/workflows/pull-request-build-lint-web-apps.yml @@ -8,8 +8,6 @@ on: types: - "opened" - "synchronize" - - "ready_for_review" - - "review_requested" - "reopened" concurrency: @@ -17,10 +15,11 @@ concurrency: cancel-in-progress: true jobs: - build-and-lint: - name: Build and lint web apps + # Format check has no build dependencies - run immediately in parallel + check-format: + name: check:format runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 10 if: | github.event.pull_request.draft == false && github.event.pull_request.requested_reviewers != null @@ -29,28 +28,178 @@ jobs: TURBO_SCM_HEAD: ${{ github.sha }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 50 filter: blob:none - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 - name: Enable Corepack and pnpm run: corepack enable pnpm + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Cache pnpm store + uses: actions/cache@v5 + with: + path: ${{ env.STORE_PATH }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build Affected - run: pnpm turbo run build --affected - - - name: Lint Affected - run: pnpm turbo run check:lint --affected - - - name: Check Affected format + - name: Check formatting run: pnpm turbo run check:format --affected - - name: Check Affected types + # Build packages - required for lint and type checks + build: + name: Build packages + runs-on: ubuntu-latest + timeout-minutes: 15 + if: | + github.event.pull_request.draft == false && + github.event.pull_request.requested_reviewers != null + env: + TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }} + TURBO_SCM_HEAD: ${{ github.sha }} + NODE_OPTIONS: "--max-old-space-size=4096" + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 50 + filter: blob:none + + - name: Set up Node.js + uses: actions/setup-node@v6 + + - name: Enable Corepack and pnpm + run: corepack enable pnpm + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Cache pnpm store + uses: actions/cache@v5 + with: + path: ${{ env.STORE_PATH }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Restore Turbo cache + uses: actions/cache/restore@v5 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }} + restore-keys: | + turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}- + turbo-${{ runner.os }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm turbo run build --affected + + - name: Save Turbo cache + uses: actions/cache/save@v5 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }} + + # Lint check - no build dependency, OxLint is a standalone Rust binary + check-lint: + name: check:lint + runs-on: ubuntu-latest + timeout-minutes: 10 + if: | + github.event.pull_request.draft == false && + github.event.pull_request.requested_reviewers != null + env: + TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }} + TURBO_SCM_HEAD: ${{ github.sha }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 50 + filter: blob:none + + - name: Set up Node.js + uses: actions/setup-node@v6 + + - name: Enable Corepack and pnpm + run: corepack enable pnpm + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Cache pnpm store + uses: actions/cache@v5 + with: + path: ${{ env.STORE_PATH }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run check:lint + run: pnpm turbo run check:lint --affected + + # Type check depends on build artifacts + check-types: + name: check:types + runs-on: ubuntu-latest + needs: build + timeout-minutes: 15 + env: + TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }} + TURBO_SCM_HEAD: ${{ github.sha }} + NODE_OPTIONS: "--max-old-space-size=4096" + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 50 + filter: blob:none + + - name: Set up Node.js + uses: actions/setup-node@v6 + + - name: Enable Corepack and pnpm + run: corepack enable pnpm + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Cache pnpm store + uses: actions/cache@v5 + with: + path: ${{ env.STORE_PATH }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Restore Turbo cache + uses: actions/cache/restore@v5 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }} + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run check:types run: pnpm turbo run check:types --affected diff --git a/.github/workflows/sync-repo-pr.yml b/.github/workflows/sync-repo-pr.yml deleted file mode 100644 index 548ccbf42..000000000 --- a/.github/workflows/sync-repo-pr.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/sync-repo.yml b/.github/workflows/sync-repo.yml deleted file mode 100644 index 5d6c72cb7..000000000 --- a/.github/workflows/sync-repo.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Sync Repositories - -on: - workflow_dispatch: - push: - branches: - - preview - -env: - SOURCE_BRANCH_NAME: ${{ github.ref_name }} - -jobs: - sync_changes: - runs-on: ubuntu-22.04 - permissions: - pull-requests: write - contents: read - steps: - - name: Checkout Code - uses: actions/checkout@v4 - with: - persist-credentials: false - fetch-depth: 0 - - - name: Setup GH CLI - run: | - type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y) - curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg - sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null - sudo apt update - sudo apt install gh -y - - - name: Push Changes to Target Repo - env: - GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} - run: | - TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}" - TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}" - SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" - - git checkout $SOURCE_BRANCH - git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git" - git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH diff --git a/.gitignore b/.gitignore index f0093a0e6..e2e6441ba 100644 --- a/.gitignore +++ b/.gitignore @@ -105,10 +105,8 @@ CLAUDE.md build/ .react-router/ -AGENTS.md build/ .react-router/ -AGENTS.md temp/ scripts/ diff --git a/.prettierrc b/.oxfmtrc.json similarity index 52% rename from .prettierrc rename to .oxfmtrc.json index ef155d4a1..cf1c0efa7 100644 --- a/.prettierrc +++ b/.oxfmtrc.json @@ -1,5 +1,11 @@ { - "$schema": "https://json.schemastore.org/prettierrc", + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5", + "sortTailwindcss": { + "stylesheet": "packages/tailwind-config/index.css", + "functions": ["cn", "clsx", "cva"] + }, "overrides": [ { "files": ["packages/codemods/**/*"], @@ -7,9 +13,5 @@ "printWidth": 80 } } - ], - "plugins": ["@prettier/plugin-oxc"], - "printWidth": 120, - "tabWidth": 2, - "trailingComma": "es5" + ] } diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 000000000..83ab91d8e --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,53 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "jsx-a11y", "import", "promise", "unicorn", "oxc"], + "categories": { + "correctness": "warn", + "suspicious": "warn", + "perf": "warn" + }, + "env": { + "browser": true, + "node": true, + "es2024": true + }, + "settings": { + "react": { + "version": "18.3" + }, + "jsx-a11y": { + "polymorphicPropName": "as" + } + }, + "ignorePatterns": [ + ".cache/**", + ".next/**", + ".react-router/**", + ".storybook/**", + ".turbo/**", + ".vite/**", + "*.config.{js,mjs,cjs,ts}", + "build/**", + "coverage/**", + "dist/**", + "**/public/**", + "storybook-static/**" + ], + "rules": { + "react/react-in-jsx-scope": "off", + "react/prop-types": "off", + "unicorn/filename-case": "off", + "unicorn/no-null": "off", + "unicorn/prevent-abbreviations": "off", + "no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_", + "destructuredArrayIgnorePattern": "^_", + "ignoreRestSiblings": true + } + ] + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..3de0b8037 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# Agent Development Guide + +## Commands + +- `pnpm dev` - Start all dev servers (web:3000, admin:3001) +- `pnpm build` - Build all packages and apps +- `pnpm check` - Run all checks (format, lint, types) +- `pnpm check:lint` - OxLint across all packages +- `pnpm check:types` - TypeScript type checking +- `pnpm fix` - Auto-fix format and lint issues +- `pnpm turbo run --filter=` - Target specific package/app +- `pnpm --filter=@plane/ui storybook` - Start Storybook on port 6006 + +## Code Style + +- **Imports**: Use `workspace:*` for internal packages, `catalog:` for external deps +- **TypeScript**: Strict mode enabled, all files must be typed +- **Formatting**: oxfmt, run `pnpm fix:format` +- **Linting**: OxLint with shared `.oxlintrc.json` config +- **Naming**: camelCase for variables/functions, PascalCase for components/types +- **Error Handling**: Use try-catch with proper error types, log errors appropriately +- **State Management**: MobX stores in `packages/shared-state`, reactive patterns +- **Testing**: All features require unit tests, use existing test framework per package +- **Components**: Build in `@plane/ui` with Storybook for isolated development diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49c61547d..d0f3d75d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,7 +91,7 @@ If you would like to _implement_ it, an issue with your proposal must be submitt To ensure consistency throughout the source code, please keep these rules in mind as you are working: - All features or bug fixes must be tested by one or more specs (unit-tests). -- We lint with [ESLint 9](https://eslint.org/docs/latest/) using the shared `eslint.config.mjs` (type-aware via `typescript-eslint`) and format with [Prettier](https://prettier.io/) using `prettier.config.cjs`. +- We lint with [OxLint](https://oxc.rs/docs/guide/usage/linter) using the shared `.oxlintrc.json` and format with [oxfmt](https://oxc.rs/docs/guide/usage/formatter) using `.oxfmtrc.json`. ## Ways to contribute @@ -244,4 +244,4 @@ Happy translating! 🌍✨ ## Need help? Questions and suggestions -Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge). +Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Forum](https://forum.plane.so). diff --git a/COPYRIGHT.txt b/COPYRIGHT.txt new file mode 100644 index 000000000..2a6fd91ff --- /dev/null +++ b/COPYRIGHT.txt @@ -0,0 +1,3 @@ +Copyright (c) 2023-present Plane Software, Inc. and contributors +SPDX-License-Identifier: AGPL-3.0-only +See the LICENSE file for details. \ No newline at end of file diff --git a/COPYRIGHT_CHECK.md b/COPYRIGHT_CHECK.md new file mode 100644 index 000000000..a72b11a24 --- /dev/null +++ b/COPYRIGHT_CHECK.md @@ -0,0 +1,34 @@ +## Copyright check + +To verify that all tracked Python files contain the correct copyright header for **Plane Software Inc.** for the year **2023**, run this command from the repository root: + +```bash +addlicense --check -f COPYRIGHT.txt -ignore "**/migrations/**" $(git ls-files '*.py') +``` + +#### To Apply Changes + +python files + +```bash +addlicense -v -f COPYRIGHT.txt -ignore "**/migrations/**" $(git ls-files '*.py') +``` + +ts and tsx files in a specific app + +```bash +addlicense -v -f COPYRIGHT.txt \ + -ignore "**/*.config.ts" \ + -ignore "**/*.d.ts" \ + $(git ls-files 'packages/*.ts') +``` + +Note: Please make sure ts command is running on specific folder, running it for the whole mono repo is crashing os processes. + +#### Other Options + +- **`addlicense -check`**: runs in check-only mode and fails if any file is missing or has an incorrect header. +- **`-c "Plane Software Inc."`**: sets the copyright holder. +- **`-f LICENSE.txt`**: uses the contents and format defined in `LICENSE.txt` as the header template. +- **`-y 2023`**: sets the year in the header. +- **`$(git ls-files '*.py')`**: restricts the check to Python files tracked in git. diff --git a/README.md b/README.md index f6b364bef..d2117149d 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,9 @@

Modern project management for all teams

-

- -Discord online members - -Commit activity per month -

-

Website • - Releases • + ForumTwitterDocumentation

@@ -33,7 +26,7 @@ Meet [Plane](https://plane.so/), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘‍♀️ -> Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Discord](https://discord.com/invite/A92xrEGCge) or raise a GitHub issue. We read everything and respond to most. +> Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Forum](https://forum.plane.so) or raise a GitHub issue. We read everything and respond to most. ## 🚀 Installation @@ -54,7 +47,7 @@ Getting started with Plane is simple. Choose the setup that works best for you: ## 🌟 Features -- **Issues** +- **Work Items** Efficiently create and manage tasks with a robust rich text editor that supports file uploads. Enhance organization and tracking by adding sub-properties and referencing related issues. - **Cycles** @@ -72,15 +65,13 @@ Getting started with Plane is simple. Choose the setup that works best for you: - **Analytics** Access real-time insights across all your Plane data. Visualize trends, remove blockers, and keep your projects moving forward. -- **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution. - ## 🛠️ Local development See [CONTRIBUTING](./CONTRIBUTING.md) ## ⚙️ Built with -[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/) +[![React Router](https://img.shields.io/badge/-React%20Router-CA4245?logo=react-router&style=for-the-badge&logoColor=white)](https://reactrouter.com/) [![Django](https://img.shields.io/badge/Django-092E20?style=for-the-badge&logo=django&logoColor=green)](https://www.djangoproject.com/) [![Node JS](https://img.shields.io/badge/node.js-339933?style=for-the-badge&logo=Node.js&logoColor=white)](https://nodejs.org/en) @@ -138,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! @@ -154,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. diff --git a/apps/admin/Dockerfile.admin b/apps/admin/Dockerfile.admin index 458d00a2e..19ad2c392 100644 --- a/apps/admin/Dockerfile.admin +++ b/apps/admin/Dockerfile.admin @@ -13,7 +13,7 @@ RUN corepack enable pnpm FROM base AS builder -RUN pnpm add -g turbo@2.6.3 +RUN pnpm add -g turbo@2.9.4 COPY . . diff --git a/apps/admin/app/(all)/(dashboard)/ai/form.tsx b/apps/admin/app/(all)/(dashboard)/ai/form.tsx index 568289033..affbda480 100644 --- a/apps/admin/app/(all)/(dashboard)/ai/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/ai/form.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useForm } from "react-hook-form"; import { Lightbulb } from "lucide-react"; import { Button } from "@plane/propel/button"; @@ -42,7 +48,7 @@ export function InstanceAIForm(props: IInstanceAIForm) { Learn more @@ -63,7 +69,7 @@ export function InstanceAIForm(props: IInstanceAIForm) { here. @@ -94,8 +100,8 @@ export function InstanceAIForm(props: IInstanceAIForm) {
-
OpenAI
-
If you use ChatGPT, this is for you.
+
OpenAI
+
If you use ChatGPT, this is for you.
{aiFormFields.map((field) => ( @@ -114,16 +120,16 @@ export function InstanceAIForm(props: IInstanceAIForm) {
-
- -
- +
+ diff --git a/apps/admin/app/(all)/(dashboard)/ai/page.tsx b/apps/admin/app/(all)/(dashboard)/ai/page.tsx index ebdf40528..dec320098 100644 --- a/apps/admin/app/(all)/(dashboard)/ai/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/ai/page.tsx @@ -1,10 +1,19 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; import useSWR from "swr"; import { Loader } from "@plane/ui"; +// components +import { PageWrapper } from "@/components/common/page-wrapper"; // hooks import { useInstance } from "@/hooks/store"; -// components +// types import type { Route } from "./+types/page"; +// local import { InstanceAIForm } from "./form"; const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentProps) { @@ -14,30 +23,25 @@ const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentP useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); return ( - <> -
-
-
AI features for all your workspaces
-
- Configure your AI API credentials so Plane AI features are turned on for all your workspaces. + + {formattedConfig ? ( + + ) : ( + + +
+ +
-
-
- {formattedConfig ? ( - - ) : ( - - -
- - -
- -
- )} -
-
- + + + )} + ); }); diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx index 15b97b588..f05464a41 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx @@ -1,18 +1,25 @@ +/** + * 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 { isEmpty } from "lodash-es"; import Link from "next/link"; import { useForm } from "react-hook-form"; // plane internal packages import { API_BASE_URL } from "@plane/constants"; +import { Button, getButtonStyling } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IFormattedInstanceConfiguration, TInstanceGiteaAuthenticationConfigurationKeys } from "@plane/types"; -import { Button, getButtonStyling } from "@plane/ui"; -import { cn } from "@plane/utils"; // components import { CodeBlock } from "@/components/common/code-block"; import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; import type { TControllerInputFormField } from "@/components/common/controller-input"; import { ControllerInput } from "@/components/common/controller-input"; +import type { TControllerSwitchFormField } from "@/components/common/controller-switch"; +import { ControllerSwitch } from "@/components/common/controller-switch"; import type { TCopyField } from "@/components/common/copy-field"; import { CopyField } from "@/components/common/copy-field"; // hooks @@ -41,6 +48,7 @@ export function InstanceGiteaConfigForm(props: Props) { GITEA_HOST: config["GITEA_HOST"] || "https://gitea.com", GITEA_CLIENT_ID: config["GITEA_CLIENT_ID"], GITEA_CLIENT_SECRET: config["GITEA_CLIENT_SECRET"], + ENABLE_GITEA_SYNC: config["ENABLE_GITEA_SYNC"] || "0", }, }); @@ -69,7 +77,7 @@ export function InstanceGiteaConfigForm(props: Props) { tabIndex={-1} href="https://gitea.com/user/settings/applications" target="_blank" - className="text-custom-primary-100 hover:underline" + className="text-accent-primary hover:underline" rel="noreferrer" > Gitea OAuth application settings. @@ -91,7 +99,7 @@ export function InstanceGiteaConfigForm(props: Props) { tabIndex={-1} href="https://gitea.com/user/settings/applications" target="_blank" - className="text-custom-primary-100 hover:underline" + className="text-accent-primary hover:underline" rel="noreferrer" > Gitea OAuth application settings. @@ -104,6 +112,11 @@ export function InstanceGiteaConfigForm(props: Props) { }, ]; + const GITEA_FORM_SWITCH_FIELD: TControllerSwitchFormField = { + name: "ENABLE_GITEA_SYNC", + label: "Gitea", + }; + const GITEA_SERVICE_FIELD: TCopyField[] = [ { key: "Callback_URI", @@ -117,7 +130,7 @@ export function InstanceGiteaConfigForm(props: Props) { tabIndex={-1} href={`${control._formValues.GITEA_HOST || "https://gitea.com"}/user/settings/applications`} target="_blank" - className="text-custom-primary-100 hover:underline" + className="text-accent-primary hover:underline" rel="noreferrer" > here. @@ -130,20 +143,22 @@ export function InstanceGiteaConfigForm(props: Props) { const onSubmit = async (formData: GiteaConfigFormValues) => { const payload: Partial = { ...formData }; - await updateInstanceConfigurations(payload) - .then((response = []) => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Done!", - message: "Your Gitea authentication is configured. You should test it now.", - }); - reset({ - GITEA_HOST: response.find((item) => item.key === "GITEA_HOST")?.value, - GITEA_CLIENT_ID: response.find((item) => item.key === "GITEA_CLIENT_ID")?.value, - GITEA_CLIENT_SECRET: response.find((item) => item.key === "GITEA_CLIENT_SECRET")?.value, - }); - }) - .catch((err) => console.error(err)); + try { + const response = await updateInstanceConfigurations(payload); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your Gitea authentication is configured. You should test it now.", + }); + reset({ + GITEA_HOST: response.find((item) => item.key === "GITEA_HOST")?.value, + GITEA_CLIENT_ID: response.find((item) => item.key === "GITEA_CLIENT_ID")?.value, + GITEA_CLIENT_SECRET: response.find((item) => item.key === "GITEA_CLIENT_SECRET")?.value, + ENABLE_GITEA_SYNC: response.find((item) => item.key === "ENABLE_GITEA_SYNC")?.value, + }); + } catch (err) { + console.error(err); + } }; const handleGoBack = (e: React.MouseEvent) => { @@ -161,9 +176,9 @@ export function InstanceGiteaConfigForm(props: Props) { handleClose={() => setIsDiscardChangesModalOpen(false)} />
-
-
-
Gitea-provided details for Plane
+
+
+
Gitea-provided details for Plane
{GITEA_FORM_FIELDS.map((field) => ( ))} +
- - void handleSubmit(onSubmit)(e)} + loading={isSubmitting} + disabled={!isDirty} > + {isSubmitting ? "Saving" : "Save changes"} + + Go back
-
-
Plane-provided details for Gitea
+
+
Plane-provided details for Gitea
{GITEA_SERVICE_FIELD.map((field) => ( ))} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitea/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitea/page.tsx index 1838cd5b8..fe8eae4c6 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/gitea/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitea/page.tsx @@ -1,16 +1,25 @@ +/** + * 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"; // plane internal packages import { setPromiseToast } from "@plane/propel/toast"; import { Loader, ToggleSwitch } from "@plane/ui"; -// components +// assets import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url"; +// components import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +import { PageWrapper } from "@/components/common/page-wrapper"; // hooks import { useInstance } from "@/hooks/store"; -//local components +// types import type { Route } from "./+types/page"; +// local import { InstanceGiteaConfigForm } from "./form"; const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthenticationPage() { @@ -32,7 +41,7 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic const updateConfigPromise = updateInstanceConfigurations(payload); setPromiseToast(updateConfigPromise, { - loading: "Saving Configuration...", + loading: "Saving Configuration", success: { title: "Configuration saved", message: () => `Gitea authentication is now ${value === "1" ? "active" : "disabled"}.`, @@ -56,42 +65,39 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic const isGiteaEnabled = enableGiteaConfig === "1"; return ( - <> -
-
- } - config={ - { - updateConfig("IS_GITEA_ENABLED", isGiteaEnabled ? "0" : "1"); - }} - size="sm" - disabled={isSubmitting || !formattedConfig} - /> - } - disabled={isSubmitting || !formattedConfig} - withBorder={false} - /> -
-
- {formattedConfig ? ( - - ) : ( - - - - - - - - )} -
-
- + } + config={ + { + updateConfig("IS_GITEA_ENABLED", isGiteaEnabled ? "0" : "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> + } + > + {formattedConfig ? ( + + ) : ( + + + + + + + + )} + ); }); export const meta: Route.MetaFunction = () => [{ title: "Gitea Authentication - God Mode" }]; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx index a93ffdb9e..2425216b3 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx @@ -1,3 +1,9 @@ +/** + * 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 { isEmpty } from "lodash-es"; import Link from "next/link"; @@ -8,12 +14,12 @@ import { API_BASE_URL } from "@plane/constants"; import { Button, getButtonStyling } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types"; - -import { cn } from "@plane/utils"; // components import { CodeBlock } from "@/components/common/code-block"; import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; import type { TControllerInputFormField } from "@/components/common/controller-input"; +import type { TControllerSwitchFormField } from "@/components/common/controller-switch"; +import { ControllerSwitch } from "@/components/common/controller-switch"; import { ControllerInput } from "@/components/common/controller-input"; import type { TCopyField } from "@/components/common/copy-field"; import { CopyField } from "@/components/common/copy-field"; @@ -43,6 +49,7 @@ export function InstanceGithubConfigForm(props: Props) { GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"], GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"], GITHUB_ORGANIZATION_ID: config["GITHUB_ORGANIZATION_ID"], + ENABLE_GITHUB_SYNC: config["ENABLE_GITHUB_SYNC"] || "0", }, }); @@ -60,7 +67,7 @@ export function InstanceGithubConfigForm(props: Props) { tabIndex={-1} href="https://github.com/settings/applications/new" target="_blank" - className="text-custom-primary-100 hover:underline" + className="text-accent-primary hover:underline" rel="noreferrer" > GitHub OAuth application settings. @@ -82,7 +89,7 @@ export function InstanceGithubConfigForm(props: Props) { tabIndex={-1} href="https://github.com/settings/applications/new" target="_blank" - className="text-custom-primary-100 hover:underline" + className="text-accent-primary hover:underline" rel="noreferrer" > GitHub OAuth application settings. @@ -104,6 +111,11 @@ export function InstanceGithubConfigForm(props: Props) { }, ]; + const GITHUB_FORM_SWITCH_FIELD: TControllerSwitchFormField = { + name: "ENABLE_GITHUB_SYNC", + label: "GitHub", + }; + const GITHUB_COMMON_SERVICE_DETAILS: TCopyField[] = [ { key: "Origin_URL", @@ -116,7 +128,7 @@ export function InstanceGithubConfigForm(props: Props) { tabIndex={-1} href="https://github.com/settings/applications/new" target="_blank" - className="text-custom-primary-100 hover:underline" + className="text-accent-primary hover:underline" rel="noreferrer" > here. @@ -139,7 +151,7 @@ export function InstanceGithubConfigForm(props: Props) { tabIndex={-1} href="https://github.com/settings/applications/new" target="_blank" - className="text-custom-primary-100 hover:underline" + className="text-accent-primary hover:underline" rel="noreferrer" > here. @@ -152,20 +164,22 @@ export function InstanceGithubConfigForm(props: Props) { const onSubmit = async (formData: GithubConfigFormValues) => { const payload: Partial = { ...formData }; - await updateInstanceConfigurations(payload) - .then((response = []) => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Done!", - message: "Your GitHub authentication is configured. You should test it now.", - }); - reset({ - GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value, - GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value, - GITHUB_ORGANIZATION_ID: response.find((item) => item.key === "GITHUB_ORGANIZATION_ID")?.value, - }); - }) - .catch((err) => console.error(err)); + try { + const response = await updateInstanceConfigurations(payload); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your GitHub authentication is configured. You should test it now.", + }); + reset({ + GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value, + GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value, + GITHUB_ORGANIZATION_ID: response.find((item) => item.key === "GITHUB_ORGANIZATION_ID")?.value, + ENABLE_GITHUB_SYNC: response.find((item) => item.key === "ENABLE_GITHUB_SYNC")?.value, + }); + } catch (err) { + console.error(err); + } }; const handleGoBack = (e: React.MouseEvent) => { @@ -183,9 +197,9 @@ export function InstanceGithubConfigForm(props: Props) { handleClose={() => setIsDiscardChangesModalOpen(false)} />
-
-
-
GitHub-provided details for Plane
+
+
+
GitHub-provided details for Plane
{GITHUB_FORM_FIELDS.map((field) => ( ))} +
- - void handleSubmit(onSubmit)(e)} + loading={isSubmitting} + disabled={!isDirty} > + {isSubmitting ? "Saving" : "Save changes"} + + Go back
-
-
Plane-provided details for GitHub
+
+
Plane-provided details for GitHub
{/* common service details */} -
+
{GITHUB_COMMON_SERVICE_DETAILS.map((field) => ( ))}
{/* web service details */} -
-
- +
+
+ Web
-
+
{GITHUB_SERVICE_DETAILS.map((field) => ( ))} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx index 4fe3c4611..a7a29cf9d 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx @@ -1,3 +1,9 @@ +/** + * 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 { useTheme } from "next-themes"; @@ -6,15 +12,17 @@ import useSWR from "swr"; import { setPromiseToast } from "@plane/propel/toast"; import { Loader, ToggleSwitch } from "@plane/ui"; import { resolveGeneralTheme } from "@plane/utils"; -// components +// assets import githubLightModeImage from "@/app/assets/logos/github-black.png?url"; import githubDarkModeImage from "@/app/assets/logos/github-white.png?url"; +// components import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +import { PageWrapper } from "@/components/common/page-wrapper"; // hooks import { useInstance } from "@/hooks/store"; -// icons -// local components +// types import type { Route } from "./+types/page"; +// local import { InstanceGithubConfigForm } from "./form"; const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthenticationPage( @@ -41,7 +49,7 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent const updateConfigPromise = updateInstanceConfigurations(payload); setPromiseToast(updateConfigPromise, { - loading: "Saving Configuration...", + loading: "Saving Configuration", success: { title: "Configuration saved", message: () => `GitHub authentication is now ${value === "1" ? "active" : "disabled"}.`, @@ -65,49 +73,46 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent const isGithubEnabled = enableGithubConfig === "1"; return ( - <> -
-
- - } - config={ - { - updateConfig("IS_GITHUB_ENABLED", isGithubEnabled ? "0" : "1"); - }} - size="sm" - disabled={isSubmitting || !formattedConfig} - /> - } - disabled={isSubmitting || !formattedConfig} - withBorder={false} - /> -
-
- {formattedConfig ? ( - - ) : ( - - - - - - - - )} -
-
- + + } + config={ + { + updateConfig("IS_GITHUB_ENABLED", isGithubEnabled ? "0" : "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> + } + > + {formattedConfig ? ( + + ) : ( + + + + + + + + )} + ); }); diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx index 65f2b776c..7df6faf17 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx @@ -1,3 +1,9 @@ +/** + * 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 { isEmpty } from "lodash-es"; import Link from "next/link"; @@ -7,11 +13,12 @@ import { API_BASE_URL } from "@plane/constants"; import { Button, getButtonStyling } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types"; -import { cn } from "@plane/utils"; // components import { CodeBlock } from "@/components/common/code-block"; import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; import type { TControllerInputFormField } from "@/components/common/controller-input"; +import type { TControllerSwitchFormField } from "@/components/common/controller-switch"; +import { ControllerSwitch } from "@/components/common/controller-switch"; import { ControllerInput } from "@/components/common/controller-input"; import type { TCopyField } from "@/components/common/copy-field"; import { CopyField } from "@/components/common/copy-field"; @@ -41,6 +48,7 @@ export function InstanceGitlabConfigForm(props: Props) { GITLAB_HOST: config["GITLAB_HOST"], GITLAB_CLIENT_ID: config["GITLAB_CLIENT_ID"], GITLAB_CLIENT_SECRET: config["GITLAB_CLIENT_SECRET"], + ENABLE_GITLAB_SYNC: config["ENABLE_GITLAB_SYNC"] || "0", }, }); @@ -71,7 +79,7 @@ export function InstanceGitlabConfigForm(props: Props) { tabIndex={-1} href="https://docs.gitlab.com/ee/integration/oauth_provider.html" target="_blank" - className="text-custom-primary-100 hover:underline" + className="text-accent-primary hover:underline" rel="noreferrer" > GitLab OAuth application settings @@ -94,7 +102,7 @@ export function InstanceGitlabConfigForm(props: Props) { tabIndex={-1} href="https://docs.gitlab.com/ee/integration/oauth_provider.html" target="_blank" - className="text-custom-primary-100 hover:underline" + className="text-accent-primary hover:underline" rel="noreferrer" > GitLab OAuth application settings @@ -108,6 +116,11 @@ export function InstanceGitlabConfigForm(props: Props) { }, ]; + const GITLAB_FORM_SWITCH_FIELD: TControllerSwitchFormField = { + name: "ENABLE_GITLAB_SYNC", + label: "GitLab", + }; + const GITLAB_SERVICE_FIELD: TCopyField[] = [ { key: "Callback_URL", @@ -120,7 +133,7 @@ export function InstanceGitlabConfigForm(props: Props) { tabIndex={-1} href="https://docs.gitlab.com/ee/integration/oauth_provider.html" target="_blank" - className="text-custom-primary-100 hover:underline" + className="text-accent-primary hover:underline" rel="noreferrer" > GitLab OAuth application @@ -134,20 +147,22 @@ export function InstanceGitlabConfigForm(props: Props) { const onSubmit = async (formData: GitlabConfigFormValues) => { const payload: Partial = { ...formData }; - await updateInstanceConfigurations(payload) - .then((response = []) => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Done!", - message: "Your GitLab authentication is configured. You should test it now.", - }); - reset({ - GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value, - GITLAB_CLIENT_ID: response.find((item) => item.key === "GITLAB_CLIENT_ID")?.value, - GITLAB_CLIENT_SECRET: response.find((item) => item.key === "GITLAB_CLIENT_SECRET")?.value, - }); - }) - .catch((err) => console.error(err)); + try { + const response = await updateInstanceConfigurations(payload); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your GitLab authentication is configured. You should test it now.", + }); + reset({ + GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value, + GITLAB_CLIENT_ID: response.find((item) => item.key === "GITLAB_CLIENT_ID")?.value, + GITLAB_CLIENT_SECRET: response.find((item) => item.key === "GITLAB_CLIENT_SECRET")?.value, + ENABLE_GITLAB_SYNC: response.find((item) => item.key === "ENABLE_GITLAB_SYNC")?.value, + }); + } catch (err) { + console.error(err); + } }; const handleGoBack = (e: React.MouseEvent) => { @@ -165,9 +180,9 @@ export function InstanceGitlabConfigForm(props: Props) { handleClose={() => setIsDiscardChangesModalOpen(false)} />
-
-
-
GitLab-provided details for Plane
+
+
+
GitLab-provided details for Plane
{GITLAB_FORM_FIELDS.map((field) => ( ))} +
- - void handleSubmit(onSubmit)(e)} + loading={isSubmitting} + disabled={!isDirty} > + {isSubmitting ? "Saving" : "Save changes"} + + Go back
-
-
Plane-provided details for GitLab
+
+
Plane-provided details for GitLab
{GITLAB_SERVICE_FIELD.map((field) => ( ))} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx index ba421e04c..5bcaef726 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx @@ -1,16 +1,24 @@ +/** + * 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 { setPromiseToast } from "@plane/propel/toast"; import { Loader, ToggleSwitch } from "@plane/ui"; -// components +// assets import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; +// components import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +import { PageWrapper } from "@/components/common/page-wrapper"; // hooks import { useInstance } from "@/hooks/store"; -// icons -// local components +// types import type { Route } from "./+types/page"; +// local import { InstanceGitlabConfigForm } from "./form"; const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthenticationPage( @@ -35,7 +43,7 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent const updateConfigPromise = updateInstanceConfigurations(payload); setPromiseToast(updateConfigPromise, { - loading: "Saving Configuration...", + loading: "Saving Configuration", success: { title: "Configuration saved", message: () => `GitLab authentication is now ${value === "1" ? "active" : "disabled"}.`, @@ -56,46 +64,43 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent }); }; return ( - <> -
-
- } - config={ - { - if (Boolean(parseInt(enableGitlabConfig)) === true) { - updateConfig("IS_GITLAB_ENABLED", "0"); - } else { - updateConfig("IS_GITLAB_ENABLED", "1"); - } - }} - size="sm" - disabled={isSubmitting || !formattedConfig} - /> - } - disabled={isSubmitting || !formattedConfig} - withBorder={false} - /> -
-
- {formattedConfig ? ( - - ) : ( - - - - - - - - )} -
-
- + } + config={ + { + if (Boolean(parseInt(enableGitlabConfig)) === true) { + updateConfig("IS_GITLAB_ENABLED", "0"); + } else { + updateConfig("IS_GITLAB_ENABLED", "1"); + } + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> + } + > + {formattedConfig ? ( + + ) : ( + + + + + + + + )} + ); }); diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx index f8cda5c0b..698ff34e9 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx @@ -1,3 +1,9 @@ +/** + * 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 { isEmpty } from "lodash-es"; import Link from "next/link"; @@ -8,11 +14,12 @@ import { API_BASE_URL } from "@plane/constants"; import { Button, getButtonStyling } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types"; -import { cn } from "@plane/utils"; // components import { CodeBlock } from "@/components/common/code-block"; import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; import type { TControllerInputFormField } from "@/components/common/controller-input"; +import type { TControllerSwitchFormField } from "@/components/common/controller-switch"; +import { ControllerSwitch } from "@/components/common/controller-switch"; import { ControllerInput } from "@/components/common/controller-input"; import type { TCopyField } from "@/components/common/copy-field"; import { CopyField } from "@/components/common/copy-field"; @@ -41,6 +48,7 @@ export function InstanceGoogleConfigForm(props: Props) { defaultValues: { GOOGLE_CLIENT_ID: config["GOOGLE_CLIENT_ID"], GOOGLE_CLIENT_SECRET: config["GOOGLE_CLIENT_SECRET"], + ENABLE_GOOGLE_SYNC: config["ENABLE_GOOGLE_SYNC"] || "0", }, }); @@ -58,7 +66,7 @@ export function InstanceGoogleConfigForm(props: Props) { tabIndex={-1} href="https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow#creatingcred" target="_blank" - className="text-custom-primary-100 hover:underline" + className="text-accent-primary hover:underline" rel="noreferrer" > Learn more @@ -80,7 +88,7 @@ export function InstanceGoogleConfigForm(props: Props) { tabIndex={-1} href="https://developers.google.com/identity/oauth2/web/guides/get-google-api-clientid" target="_blank" - className="text-custom-primary-100 hover:underline" + className="text-accent-primary hover:underline" rel="noreferrer" > Learn more @@ -93,6 +101,11 @@ export function InstanceGoogleConfigForm(props: Props) { }, ]; + const GOOGLE_FORM_SWITCH_FIELD: TControllerSwitchFormField = { + name: "ENABLE_GOOGLE_SYNC", + label: "Google", + }; + const GOOGLE_COMMON_SERVICE_DETAILS: TCopyField[] = [ { key: "Origin_URL", @@ -105,7 +118,7 @@ export function InstanceGoogleConfigForm(props: Props) { here. @@ -127,7 +140,7 @@ export function InstanceGoogleConfigForm(props: Props) { here. @@ -140,19 +153,21 @@ export function InstanceGoogleConfigForm(props: Props) { const onSubmit = async (formData: GoogleConfigFormValues) => { const payload: Partial = { ...formData }; - await updateInstanceConfigurations(payload) - .then((response = []) => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Done!", - message: "Your Google authentication is configured. You should test it now.", - }); - reset({ - GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value, - GOOGLE_CLIENT_SECRET: response.find((item) => item.key === "GOOGLE_CLIENT_SECRET")?.value, - }); - }) - .catch((err) => console.error(err)); + try { + const response = await updateInstanceConfigurations(payload); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your Google authentication is configured. You should test it now.", + }); + reset({ + GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value, + GOOGLE_CLIENT_SECRET: response.find((item) => item.key === "GOOGLE_CLIENT_SECRET")?.value, + ENABLE_GOOGLE_SYNC: response.find((item) => item.key === "ENABLE_GOOGLE_SYNC")?.value, + }); + } catch (err) { + console.error(err); + } }; const handleGoBack = (e: React.MouseEvent) => { @@ -170,9 +185,9 @@ export function InstanceGoogleConfigForm(props: Props) { handleClose={() => setIsDiscardChangesModalOpen(false)} />
-
-
-
Google-provided details for Plane
+
+
+
Google-provided details for Plane
{GOOGLE_FORM_FIELDS.map((field) => ( ))} +
- - void handleSubmit(onSubmit)(e)} + loading={isSubmitting} + disabled={!isDirty} > + {isSubmitting ? "Saving" : "Save changes"} + + Go back
-
-
Plane-provided details for Google
+
+
Plane-provided details for Google
{/* common service details */} -
+
{GOOGLE_COMMON_SERVICE_DETAILS.map((field) => ( ))}
{/* web service details */} -
-
- +
+
+ Web
-
+
{GOOGLE_SERVICE_DETAILS.map((field) => ( ))} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx index c99c59787..93a61497d 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx @@ -1,16 +1,24 @@ +/** + * 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 { setPromiseToast } from "@plane/propel/toast"; import { Loader, ToggleSwitch } from "@plane/ui"; -// components +// assets import GoogleLogo from "@/app/assets/logos/google-logo.svg?url"; +// components import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +import { PageWrapper } from "@/components/common/page-wrapper"; // hooks import { useInstance } from "@/hooks/store"; -// icons -// local components +// types import type { Route } from "./+types/page"; +// local import { InstanceGoogleConfigForm } from "./form"; const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthenticationPage( @@ -35,7 +43,7 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent const updateConfigPromise = updateInstanceConfigurations(payload); setPromiseToast(updateConfigPromise, { - loading: "Saving Configuration...", + loading: "Saving Configuration", success: { title: "Configuration saved", message: () => `Google authentication is now ${value === "1" ? "active" : "disabled"}.`, @@ -56,47 +64,44 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent }); }; return ( - <> -
-
- } - config={ - { - if (Boolean(parseInt(enableGoogleConfig)) === true) { - updateConfig("IS_GOOGLE_ENABLED", "0"); - } else { - updateConfig("IS_GOOGLE_ENABLED", "1"); - } - }} - size="sm" - disabled={isSubmitting || !formattedConfig} - /> - } - disabled={isSubmitting || !formattedConfig} - withBorder={false} - /> -
-
- {formattedConfig ? ( - - ) : ( - - - - - - - - )} -
-
- + icon={Google Logo} + config={ + { + if (Boolean(parseInt(enableGoogleConfig)) === true) { + updateConfig("IS_GOOGLE_ENABLED", "0"); + } else { + updateConfig("IS_GOOGLE_ENABLED", "1"); + } + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> + } + > + {formattedConfig ? ( + + ) : ( + + + + + + + + )} + ); }); diff --git a/apps/admin/app/(all)/(dashboard)/authentication/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/page.tsx index c4bdb727b..26e5fc56f 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/page.tsx @@ -1,112 +1,171 @@ -import { useState } from "react"; +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; import useSWR from "swr"; // plane internal packages -import { setPromiseToast } from "@plane/propel/toast"; -import type { TInstanceConfigurationKeys } from "@plane/types"; +import { setPromiseToast, setToast, TOAST_TYPE } from "@plane/propel/toast"; +import type { TInstanceConfigurationKeys, TInstanceAuthenticationModes } from "@plane/types"; import { Loader, ToggleSwitch } from "@plane/ui"; -import { cn } from "@plane/utils"; +import { cn, resolveGeneralTheme } from "@plane/utils"; +// components +import { PageWrapper } from "@/components/common/page-wrapper"; +import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +// helpers +import { canDisableAuthMethod } from "@/helpers/authentication"; // hooks +import { useAuthenticationModes } from "@/hooks/oauth"; import { useInstance } from "@/hooks/store"; -// plane admin components -import { AuthenticationModes } from "@/plane-admin/components/authentication"; +// types import type { Route } from "./+types/page"; const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(_props: Route.ComponentProps) { - // store - const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); - - useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); - + // theme + const { resolvedTheme: resolvedThemeAdmin } = useTheme(); + const resolvedTheme = resolveGeneralTheme(resolvedThemeAdmin); + // Ref to store authentication modes for validation (avoids circular dependency) + const authenticationModesRef = useRef([]); // state const [isSubmitting, setIsSubmitting] = useState(false); + // store hooks + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); // derived values const enableSignUpConfig = formattedConfig?.ENABLE_SIGNUP ?? ""; - const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => { - setIsSubmitting(true); + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); - const payload = { - [key]: value, - }; + // Create updateConfig with validation - uses authenticationModesRef for current modes + const updateConfig = useCallback( + (key: TInstanceConfigurationKeys, value: string): void => { + // Check if trying to disable (value === "0") + if (value === "0") { + // Check if this key is an authentication method key + const currentAuthModes = authenticationModesRef.current; + const isAuthMethodKey = currentAuthModes.some((method) => method.enabledConfigKey === key); - const updateConfigPromise = updateInstanceConfigurations(payload); + // Only validate if this is an authentication method key + if (isAuthMethodKey) { + const canDisable = canDisableAuthMethod(key, currentAuthModes, formattedConfig); - setPromiseToast(updateConfigPromise, { - loading: "Saving configuration", - success: { - title: "Success", - message: () => "Configuration saved successfully", - }, - error: { - title: "Error", - message: () => "Failed to save configuration", - }, - }); + if (!canDisable) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Cannot disable authentication", + message: + "At least one authentication method must remain enabled. Please enable another method before disabling this one.", + }); + return; + } + } + } - await updateConfigPromise - .then(() => { - setIsSubmitting(false); - }) - .catch((err) => { - console.error(err); - setIsSubmitting(false); + // Proceed with the update + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving configuration", + success: { + title: "Success", + message: () => "Configuration saved successfully", + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, }); - }; + + void updateConfigPromise + .then(() => { + setIsSubmitting(false); + return undefined; + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }, + [formattedConfig, updateInstanceConfigurations] + ); + + // Get authentication modes - this will use updateConfig which includes validation + const authenticationModes = useAuthenticationModes({ + disabled: isSubmitting, + updateConfig, + resolvedTheme, + }); + + // Update ref with latest authentication modes + authenticationModesRef.current = authenticationModes; return ( - <> -
-
-
Manage authentication modes for your instance
-
- Configure authentication modes for your team and restrict sign-ups to be invite only. -
-
-
- {formattedConfig ? ( -
-
-
-
-
Allow anyone to sign up even without an invite
-
- Toggling this off will only let users sign up when they are invited. -
-
-
-
-
- { - if (Boolean(parseInt(enableSignUpConfig)) === true) { - updateConfig("ENABLE_SIGNUP", "0"); - } else { - updateConfig("ENABLE_SIGNUP", "1"); - } - }} - size="sm" - disabled={isSubmitting} - /> -
+ + {formattedConfig ? ( +
+
+
+
+
Allow anyone to sign up even without an invite
+
+ Toggling this off will only let users sign up when they are invited.
-
Available authentication modes
-
- ) : ( - - - - - - - - )} +
+
+ { + if (Boolean(parseInt(enableSignUpConfig)) === true) { + updateConfig("ENABLE_SIGNUP", "0"); + } else { + updateConfig("ENABLE_SIGNUP", "1"); + } + }} + size="sm" + disabled={isSubmitting} + /> +
+
+
+
Available authentication modes
+ {authenticationModes.map((method) => ( + + ))}
-
- + ) : ( + + + + + + + + )} + ); }); diff --git a/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx b/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx index 014be3810..794d39a3a 100644 --- a/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx +++ b/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; // types @@ -157,12 +163,12 @@ export function InstanceEmailForm(props: IInstanceEmailForm) { /> ))}
-

Email security

+

Email security

{Object.entries(EMAIL_SECURITY_OPTIONS).map(([key, value]) => ( @@ -173,12 +179,12 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
-
+
-
Authentication
-
+
Authentication
+
This is optional, but we recommend setting up a username and a password for your SMTP server.
@@ -201,17 +207,19 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
-
+
{sendEmailStep === ESendEmailSteps.SEND_EMAIL && ( - )}
diff --git a/apps/admin/app/(all)/(dashboard)/general/form.tsx b/apps/admin/app/(all)/(dashboard)/general/form.tsx index db663f777..0b402b76c 100644 --- a/apps/admin/app/(all)/(dashboard)/general/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/general/form.tsx @@ -1,17 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; import { Telescope } from "lucide-react"; -// types +// plane imports import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IInstance, IInstanceAdmin } from "@plane/types"; -// ui import { Input, ToggleSwitch } from "@plane/ui"; // components import { ControllerInput } from "@/components/common/controller-input"; -import { useInstance } from "@/hooks/store"; -import { IntercomConfig } from "./intercom"; // hooks +import { useInstance } from "@/hooks/store"; +// components +import { IntercomConfig } from "./intercom"; export interface IGeneralConfigurationForm { instance: IInstance; @@ -27,8 +33,8 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo const { handleSubmit, control, - watch, formState: { errors, isSubmitting }, + watch, } = useForm>({ defaultValues: { instance_name: instance?.instance_name, @@ -63,8 +69,8 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo return (
-
-
Instance details
+
+
Instance details
-

Email

+

Email

-

Instance ID

+

Instance ID

-
-
Chat + telemetry
+
+
Chat + telemetry
-
-
+
+
-
- +
+
-
- Let Plane collect anonymous usage data -
- diff --git a/apps/admin/app/(all)/(dashboard)/general/intercom.tsx b/apps/admin/app/(all)/(dashboard)/general/intercom.tsx index a7659f425..656e2d690 100644 --- a/apps/admin/app/(all)/(dashboard)/general/intercom.tsx +++ b/apps/admin/app/(all)/(dashboard)/general/intercom.tsx @@ -1,3 +1,9 @@ +/** + * 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"; @@ -44,22 +50,22 @@ export const IntercomConfig = observer(function IntercomConfig(props: TIntercomC }; const enableIntercomConfig = () => { - submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" }); + void submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" }); }; return ( <> -
-
+
+
-
- +
+
-
Chat with us
-
+
Chat with us
+
Let your users chat with us via Intercom or another service. Toggling Telemetry off turns this off automatically.
diff --git a/apps/admin/app/(all)/(dashboard)/general/page.tsx b/apps/admin/app/(all)/(dashboard)/general/page.tsx index 5a70e30aa..cb0a8c662 100644 --- a/apps/admin/app/(all)/(dashboard)/general/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/general/page.tsx @@ -1,30 +1,32 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; +// components +import { PageWrapper } from "@/components/common/page-wrapper"; // hooks import { useInstance } from "@/hooks/store"; -// components -import type { Route } from "./+types/page"; +// local imports import { GeneralConfigurationForm } from "./form"; +// types +import type { Route } from "./+types/page"; function GeneralPage() { const { instance, instanceAdmins } = useInstance(); return ( - <> -
-
-
General settings
-
- Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your - instance. -
-
-
- {instance && instanceAdmins && ( - - )} -
-
- + + {instance && instanceAdmins && } + ); } diff --git a/apps/admin/app/(all)/(dashboard)/image/form.tsx b/apps/admin/app/(all)/(dashboard)/image/form.tsx index 9227a5ba9..72ab51339 100644 --- a/apps/admin/app/(all)/(dashboard)/image/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/image/form.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useForm } from "react-hook-form"; import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; @@ -56,7 +62,7 @@ export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
Learn more. @@ -70,8 +76,8 @@ export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
-
diff --git a/apps/admin/app/(all)/(dashboard)/image/page.tsx b/apps/admin/app/(all)/(dashboard)/image/page.tsx index 57dd3a0fd..e410e87eb 100644 --- a/apps/admin/app/(all)/(dashboard)/image/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/image/page.tsx @@ -1,10 +1,19 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; import useSWR from "swr"; import { Loader } from "@plane/ui"; +// components +import { PageWrapper } from "@/components/common/page-wrapper"; // hooks import { useInstance } from "@/hooks/store"; -// local +// types import type { Route } from "./+types/page"; +// local import { InstanceImageConfigForm } from "./form"; const InstanceImagePage = observer(function InstanceImagePage(_props: Route.ComponentProps) { @@ -14,26 +23,21 @@ const InstanceImagePage = observer(function InstanceImagePage(_props: Route.Comp useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); return ( - <> -
-
-
Third-party image libraries
-
- Let your users search and choose images from third-party libraries -
-
-
- {formattedConfig ? ( - - ) : ( - - - - - )} -
-
- + + {formattedConfig ? ( + + ) : ( + + + + + )} + ); }); diff --git a/apps/admin/app/(all)/(dashboard)/layout.tsx b/apps/admin/app/(all)/(dashboard)/layout.tsx index b0a766d2f..e56acf7f9 100644 --- a/apps/admin/app/(all)/(dashboard)/layout.tsx +++ b/apps/admin/app/(all)/(dashboard)/layout.tsx @@ -1,15 +1,21 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useEffect } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/navigation"; import { Outlet } from "react-router"; // components +import { AdminHeader } from "@/components/common/header"; import { LogoSpinner } from "@/components/common/logo-spinner"; -import { NewUserPopup } from "@/components/new-user-popup"; +import { NewUserPopup } from "@/components/common/new-user-popup"; // hooks import { useUser } from "@/hooks/store"; // local components import type { Route } from "./+types/layout"; -import { AdminHeader } from "./header"; import { AdminSidebar } from "./sidebar"; function AdminLayout(_props: Route.ComponentProps) { @@ -34,9 +40,9 @@ function AdminLayout(_props: Route.ComponentProps) { return (
-
+
-
+
diff --git a/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx index f2458f869..d1ddb1f5a 100644 --- a/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { Fragment, useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useTheme as useNextTheme } from "next-themes"; @@ -33,20 +39,20 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() { const getSidebarMenuItems = () => (
- {currentUser?.email} + {currentUser?.email}
@@ -59,7 +65,7 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() { Sign out @@ -71,14 +77,14 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() { useEffect(() => { if (csrfToken === undefined) - authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + void authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); }, [csrfToken]); return ( -
+
@@ -88,8 +94,8 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() { "cursor-default": !isSidebarCollapsed, })} > -
- +
+
{isSidebarCollapsed && ( @@ -109,7 +115,7 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() { {!isSidebarCollapsed && (
-

Instance admin

+

Instance admin

)}
@@ -123,7 +129,7 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() { src={getFileURL(currentUser.avatar_url)} size={24} shape="square" - className="!text-base" + className="!text-body-sm-medium" /> diff --git a/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx index da09ef348..51401f312 100644 --- a/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx @@ -1,19 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useState, useRef } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { ExternalLink, HelpCircle, MoveLeft } from "lucide-react"; +import { HelpCircle, MessageSquare, MoveLeft } from "lucide-react"; import { Transition } from "@headlessui/react"; -// plane internal packages import { WEB_BASE_URL } from "@plane/constants"; -import { DiscordIcon, GithubIcon, PageIcon } from "@plane/propel/icons"; +// plane internal packages +import { GithubIcon, NewTabIcon, PageIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; import { cn } from "@plane/utils"; // hooks -import { useTheme } from "@/hooks/store"; +import { useInstance, useTheme } from "@/hooks/store"; // assets -import packageJson from "package.json"; - const helpOptions = [ { name: "Documentation", @@ -21,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", @@ -36,6 +40,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection // states const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); // store + const { instance } = useInstance(); const { isSidebarCollapsed, toggleSidebar } = useTheme(); // refs const helpOptionsRef = useRef(null); @@ -45,9 +50,9 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection return (
@@ -96,9 +101,9 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection leaveTo="transform opacity-0 scale-95" >
@@ -106,11 +111,11 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection if (href) return ( -
+
- +
- {name} + {name}
); @@ -119,17 +124,17 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection ); })}
-
Version: v{packageJson.version}
+
Version: v{instance?.current_version}
diff --git a/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx index 4fe17e0bf..319cf0173 100644 --- a/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx @@ -1,58 +1,26 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; // plane internal packages -import { WorkspaceIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; import { cn } from "@plane/utils"; // hooks import { useTheme } from "@/hooks/store"; - -const INSTANCE_ADMIN_LINKS = [ - { - Icon: Cog, - name: "General", - description: "Identify your instances and get key details.", - href: `/general/`, - }, - { - Icon: WorkspaceIcon, - name: "Workspaces", - description: "Manage all workspaces on this instance.", - href: `/workspace/`, - }, - { - Icon: Mail, - name: "Email", - description: "Configure your SMTP controls.", - href: `/email/`, - }, - { - Icon: Lock, - name: "Authentication", - description: "Configure authentication modes.", - href: `/authentication/`, - }, - { - Icon: BrainCog, - name: "Artificial intelligence", - description: "Configure your OpenAI creds.", - href: `/ai/`, - }, - { - Icon: Image, - name: "Images in Plane", - description: "Allow third-party image libraries.", - href: `/image/`, - }, -]; +import { useSidebarMenu } from "@/hooks/use-sidebar-menu"; export const AdminSidebarMenu = observer(function AdminSidebarMenu() { - // store hooks - const { isSidebarCollapsed, toggleSidebar } = useTheme(); // router const pathName = usePathname(); + // store hooks + const { isSidebarCollapsed, toggleSidebar } = useTheme(); + // derived values + const sidebarMenu = useSidebarMenu(); const handleItemClick = () => { if (window.innerWidth < 768) { @@ -61,41 +29,28 @@ export const AdminSidebarMenu = observer(function AdminSidebarMenu() { }; return ( -
- {INSTANCE_ADMIN_LINKS.map((item, index) => { - const isActive = item.href === pathName || pathName.includes(item.href); +
+ {sidebarMenu.map((item, index) => { + const isActive = item.href === pathName || pathName?.includes(item.href); return (
{} {!isSidebarCollapsed && ( -
-
- {item.name} -
-
- {item.description} -
+
+
{item.name}
+
{item.description}
)}
diff --git a/apps/admin/app/(all)/(dashboard)/sidebar.tsx b/apps/admin/app/(all)/(dashboard)/sidebar.tsx index 7950879c1..6d9c970c2 100644 --- a/apps/admin/app/(all)/(dashboard)/sidebar.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useEffect, useRef } from "react"; import { observer } from "mobx-react"; // plane helpers @@ -38,13 +44,7 @@ export const AdminSidebar = observer(function AdminSidebar() { return (
diff --git a/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx b/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx index e4c7075aa..d250b7630 100644 --- a/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useState, useEffect } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -8,6 +14,7 @@ import { Button, getButtonStyling } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { InstanceWorkspaceService } from "@plane/services"; import type { IWorkspace } from "@plane/types"; +import { validateSlug, validateWorkspaceName } from "@plane/utils"; // components import { CustomSelect, Input } from "@plane/ui"; // hooks @@ -84,20 +91,13 @@ export function WorkspaceCreateForm() {
-

Name your workspace

+

Name your workspace

- /^[\w\s-]*$/.test(value) || - `Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`, - maxLength: { - value: 80, - message: "Limit your name to 80 characters.", - }, + validate: (value) => validateWorkspaceName(value, true), }} render={({ field: { value, ref, onChange } }) => ( )} /> - {errors?.name?.message} + {errors?.name?.message}
-

Set your workspace's URL

-
- {workspaceBaseURL} +

Set your workspace's URL

+
+ {workspaceBaseURL} validateSlug(value), }} render={({ field: { onChange, value, ref } }) => ( )} />
- {slugError &&

This URL is taken. Try something else.

} + {slugError &&

This URL is taken. Try something else.

} {invalidSlug && ( -

{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}

+

{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}

)} - {errors.slug && {errors.slug.message}} + {errors.slug && {errors.slug.message}}
-

How many people will use this workspace?

+

How many people will use this workspace?

c === value) ?? ( - Select a range + Select a range ) } - buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none" + buttonClassName="!border-[0.5px] !border-subtle !shadow-none" input > {ORGANIZATION_SIZE.map((item) => ( @@ -187,22 +183,22 @@ export function WorkspaceCreateForm() { )} /> {errors.organization_size && ( - {errors.organization_size.message} + {errors.organization_size.message} )}
-
+
- + Go back
diff --git a/apps/admin/app/(all)/(dashboard)/workspace/create/page.tsx b/apps/admin/app/(all)/(dashboard)/workspace/create/page.tsx index fe3fc033a..a31d03d13 100644 --- a/apps/admin/app/(all)/(dashboard)/workspace/create/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/workspace/create/page.tsx @@ -1,21 +1,27 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; // components +import { PageWrapper } from "@/components/common/page-wrapper"; +// types import type { Route } from "./+types/page"; +// local import { WorkspaceCreateForm } from "./form"; const WorkspaceCreatePage = observer(function WorkspaceCreatePage(_props: Route.ComponentProps) { return ( -
-
-
Create a new workspace on this instance.
-
- You will need to invite users from Workspace Settings after you create this workspace. -
-
-
- -
-
+ + + ); }); diff --git a/apps/admin/app/(all)/(dashboard)/workspace/page.tsx b/apps/admin/app/(all)/(dashboard)/workspace/page.tsx index f5d8a678c..5816ac959 100644 --- a/apps/admin/app/(all)/(dashboard)/workspace/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/workspace/page.tsx @@ -1,3 +1,9 @@ +/** + * 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 Link from "next/link"; @@ -8,12 +14,13 @@ import { Button, getButtonStyling } from "@plane/propel/button"; import { setPromiseToast } from "@plane/propel/toast"; import type { TInstanceConfigurationKeys } from "@plane/types"; import { Loader, ToggleSwitch } from "@plane/ui"; - import { cn } from "@plane/utils"; // components +import { PageWrapper } from "@/components/common/page-wrapper"; import { WorkspaceListItem } from "@/components/workspace/list-item"; // hooks import { useInstance, useWorkspace } from "@/hooks/store"; +// types import type { Route } from "./+types/page"; const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props: Route.ComponentProps) { @@ -68,101 +75,95 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props }; return ( -
-
-
-
Workspaces on this instance
-
- See all workspaces and control who can create them. -
-
-
-
-
- {formattedConfig ? ( -
-
-
-
Prevent anyone else from creating a workspace.
-
- Toggling this on will let only you create workspaces. You will have to invite users to new - workspaces. -
-
-
-
-
- { - if (Boolean(parseInt(disableWorkspaceCreation)) === true) { - updateConfig("DISABLE_WORKSPACE_CREATION", "0"); - } else { - updateConfig("DISABLE_WORKSPACE_CREATION", "1"); - } - }} - size="sm" - disabled={isSubmitting} - /> + +
+ {formattedConfig ? ( +
+
+
+
Prevent anyone else from creating a workspace.
+
+ Toggling this on will let only you create workspaces. You will have to invite users to new workspaces.
- ) : ( - - - - )} - {workspaceLoader !== "init-loader" ? ( - <> -
-
-
- All workspaces on this instance{" "} - • {workspaceIds.length} - {workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && ( - - )} -
-
- You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a - Member. -
+
+
+ { + if (Boolean(parseInt(disableWorkspaceCreation)) === true) { + updateConfig("DISABLE_WORKSPACE_CREATION", "0"); + } else { + updateConfig("DISABLE_WORKSPACE_CREATION", "1"); + } + }} + size="sm" + disabled={isSubmitting} + /> +
+
+
+ ) : ( + + + + )} + {workspaceLoader !== "init-loader" ? ( + <> +
+
+
+ All workspaces on this instance • {workspaceIds.length} + {workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && ( + + )}
-
- - Create workspace - +
+ You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a + Member.
-
- {workspaceIds.map((workspaceId) => ( - - ))} +
+ + Create workspace +
- {hasNextPage && ( -
- -
- )} - - ) : ( - - - - - - - )} -
+
+
+ {workspaceIds.map((workspaceId) => ( + + ))} +
+ {hasNextPage && ( +
+ +
+ )} + + ) : ( + + + + + + + )}
-
+ ); }); diff --git a/apps/admin/app/(all)/(home)/auth-banner.tsx b/apps/admin/app/(all)/(home)/auth-banner.tsx index 3b8fd5ce5..43df781bb 100644 --- a/apps/admin/app/(all)/(home)/auth-banner.tsx +++ b/apps/admin/app/(all)/(home)/auth-banner.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { Info } from "lucide-react"; // plane constants import type { TAdminAuthErrorInfo } from "@plane/constants"; @@ -14,16 +20,16 @@ export function AuthBanner(props: TAuthBanner) { if (!bannerData) return <>; return ( -
-
- +
+
+
-
{bannerData?.message}
+
{bannerData?.message}
handleBannerData && handleBannerData(undefined)} > - +
); diff --git a/apps/admin/app/(all)/(home)/auth-header.tsx b/apps/admin/app/(all)/(home)/auth-header.tsx index ca2196eda..ae94821af 100644 --- a/apps/admin/app/(all)/(home)/auth-header.tsx +++ b/apps/admin/app/(all)/(home)/auth-header.tsx @@ -1,11 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import Link from "next/link"; import { PlaneLockup } from "@plane/propel/icons"; export function AuthHeader() { return ( -
+
- +
); diff --git a/apps/admin/app/(all)/(home)/auth-helpers.tsx b/apps/admin/app/(all)/(home)/auth-helpers.tsx index c079759a4..ea18dc995 100644 --- a/apps/admin/app/(all)/(home)/auth-helpers.tsx +++ b/apps/admin/app/(all)/(home)/auth-helpers.tsx @@ -1,21 +1,13 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import Link from "next/link"; -import { KeyRound, Mails } from "lucide-react"; // plane packages import type { TAdminAuthErrorInfo } from "@plane/constants"; import { SUPPORT_EMAIL, EAdminAuthErrorCodes } from "@plane/constants"; -import type { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types"; -import { resolveGeneralTheme } from "@plane/utils"; -// components -import githubLightModeImage from "@/app/assets/logos/github-black.png?url"; -import githubDarkModeImage from "@/app/assets/logos/github-white.png?url"; -import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; -import GoogleLogo from "@/app/assets/logos/google-logo.svg?url"; -import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch"; -import { GithubConfiguration } from "@/components/authentication/github-config"; -import { GitlabConfiguration } from "@/components/authentication/gitlab-config"; -import { GoogleConfiguration } from "@/components/authentication/google-config"; -import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch"; -// images export enum EErrorAlertType { BANNER_ALERT = "BANNER_ALERT", @@ -58,7 +50,7 @@ const errorCodeMessages: { message: () => (
Admin user already exists.  - + Sign In  now. @@ -70,7 +62,7 @@ const errorCodeMessages: { message: () => (
Admin user does not exist.  - + Sign In  now. @@ -106,53 +98,3 @@ export const authErrorHandler = (errorCode: EAdminAuthErrorCodes, email?: string return undefined; }; - -export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({ - disabled, - updateConfig, - resolvedTheme, -}) => [ - { - key: "unique-codes", - name: "Unique codes", - description: - "Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.", - icon: , - config: , - }, - { - key: "passwords-login", - name: "Passwords", - description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.", - icon: , - config: , - }, - { - key: "google", - name: "Google", - description: "Allow members to log in or sign up for Plane with their Google accounts.", - icon: Google Logo, - config: , - }, - { - key: "github", - name: "GitHub", - description: "Allow members to log in or sign up for Plane with their GitHub accounts.", - icon: ( - GitHub Logo - ), - config: , - }, - { - key: "gitlab", - name: "GitLab", - description: "Allow members to log in or sign up to plane with their GitLab accounts.", - icon: GitLab Logo, - config: , - }, -]; diff --git a/apps/admin/app/(all)/(home)/layout.tsx b/apps/admin/app/(all)/(home)/layout.tsx index 658088ff8..a33453684 100644 --- a/apps/admin/app/(all)/(home)/layout.tsx +++ b/apps/admin/app/(all)/(home)/layout.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useEffect } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/navigation"; @@ -16,7 +22,7 @@ function RootLayout() { }, [replace, isUserLoggedIn]); return ( -
+
); diff --git a/apps/admin/app/(all)/(home)/page.tsx b/apps/admin/app/(all)/(home)/page.tsx index 12f701c46..7947adcdc 100644 --- a/apps/admin/app/(all)/(home)/page.tsx +++ b/apps/admin/app/(all)/(home)/page.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; // components import { LogoSpinner } from "@/components/common/logo-spinner"; @@ -16,7 +22,7 @@ function HomePage() { // if instance is not fetched, show loading if (!instance && !error) { return ( -
+
); diff --git a/apps/admin/app/(all)/(home)/sign-in-form.tsx b/apps/admin/app/(all)/(home)/sign-in-form.tsx index 3d485ebce..4e0afb8ea 100644 --- a/apps/admin/app/(all)/(home)/sign-in-form.tsx +++ b/apps/admin/app/(all)/(home)/sign-in-form.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { Eye, EyeOff } from "lucide-react"; @@ -10,7 +16,7 @@ import { Input, Spinner } from "@plane/ui"; // components import { Banner } from "@/components/common/banner"; // local components -import { FormHeader } from "../../../core/components/instance/form-header"; +import { FormHeader } from "@/components/instance/form-header"; import { AuthBanner } from "./auth-banner"; import { AuthHeader } from "./auth-header"; import { authErrorHandler } from "./auth-helpers"; @@ -105,8 +111,8 @@ export function InstanceSignInForm() { return ( <> -
-
+
+
-
-
-
diff --git a/apps/admin/app/assets/logos/oidc-logo.svg b/apps/admin/app/assets/logos/oidc-logo.svg deleted file mode 100644 index 68bc72d01..000000000 --- a/apps/admin/app/assets/logos/oidc-logo.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/apps/admin/app/assets/logos/saml-logo.svg b/apps/admin/app/assets/logos/saml-logo.svg deleted file mode 100644 index 4cbb4f81d..000000000 --- a/apps/admin/app/assets/logos/saml-logo.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/apps/admin/app/compat/next/helper.ts b/apps/admin/app/compat/next/helper.ts index c04699870..c4edf3d54 100644 --- a/apps/admin/app/compat/next/helper.ts +++ b/apps/admin/app/compat/next/helper.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + /** * Ensures that a URL has a trailing slash while preserving query parameters and fragments * @param url - The URL to process diff --git a/apps/admin/app/compat/next/image.tsx b/apps/admin/app/compat/next/image.tsx index 062638de4..12a2bb21e 100644 --- a/apps/admin/app/compat/next/image.tsx +++ b/apps/admin/app/compat/next/image.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import React from "react"; // Minimal shim so code using next/image compiles under React Router + Vite diff --git a/apps/admin/app/compat/next/link.tsx b/apps/admin/app/compat/next/link.tsx index b0bca4faf..85177560f 100644 --- a/apps/admin/app/compat/next/link.tsx +++ b/apps/admin/app/compat/next/link.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import React from "react"; import { Link as RRLink } from "react-router"; import { ensureTrailingSlash } from "./helper"; diff --git a/apps/admin/app/compat/next/navigation.ts b/apps/admin/app/compat/next/navigation.ts index e0e6e9025..dc59a9a85 100644 --- a/apps/admin/app/compat/next/navigation.ts +++ b/apps/admin/app/compat/next/navigation.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useMemo } from "react"; import { useLocation, useNavigate, useSearchParams as useSearchParamsRR } from "react-router"; import { ensureTrailingSlash } from "./helper"; diff --git a/apps/admin/app/components/404.tsx b/apps/admin/app/components/404.tsx index 3851daa32..473dbb6e1 100644 --- a/apps/admin/app/components/404.tsx +++ b/apps/admin/app/components/404.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import React from "react"; import { Link } from "react-router"; // ui @@ -7,22 +13,22 @@ import Image404 from "@/app/assets/images/404.svg?url"; function PageNotFound() { return ( -
+
404 - Page not found
-

Oops! Something went wrong.

-

+

Oops! Something went wrong.

+

Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is temporarily unavailable.

- diff --git a/apps/admin/app/entry.client.tsx b/apps/admin/app/entry.client.tsx index 9cf1c32de..9c665ede0 100644 --- a/apps/admin/app/entry.client.tsx +++ b/apps/admin/app/entry.client.tsx @@ -1,28 +1,13 @@ -import * as Sentry from "@sentry/react-router"; +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; -Sentry.init({ - dsn: process.env.VITE_SENTRY_DSN, - environment: process.env.VITE_SENTRY_ENVIRONMENT, - sendDefaultPii: process.env.VITE_SENTRY_SEND_DEFAULT_PII ? process.env.VITE_SENTRY_SEND_DEFAULT_PII === "1" : false, - release: process.env.VITE_APP_VERSION, - tracesSampleRate: process.env.VITE_SENTRY_TRACES_SAMPLE_RATE - ? parseFloat(process.env.VITE_SENTRY_TRACES_SAMPLE_RATE) - : 0.1, - profilesSampleRate: process.env.VITE_SENTRY_PROFILES_SAMPLE_RATE - ? parseFloat(process.env.VITE_SENTRY_PROFILES_SAMPLE_RATE) - : 0.1, - replaysSessionSampleRate: process.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE - ? parseFloat(process.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE) - : 0.1, - replaysOnErrorSampleRate: process.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE - ? parseFloat(process.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE) - : 1.0, - integrations: [], -}); - startTransition(() => { hydrateRoot( document, diff --git a/apps/admin/app/root.tsx b/apps/admin/app/root.tsx index 89415106d..5d4eafb76 100644 --- a/apps/admin/app/root.tsx +++ b/apps/admin/app/root.tsx @@ -1,5 +1,10 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import type { ReactNode } from "react"; -import * as Sentry from "@sentry/react-router"; import { Links, Meta, Outlet, Scripts } from "react-router"; import type { LinksFunction } from "react-router"; import appleTouchIcon from "@/app/assets/favicon/apple-touch-icon.png?url"; @@ -8,8 +13,13 @@ import favicon32 from "@/app/assets/favicon/favicon-32x32.png?url"; import faviconIco from "@/app/assets/favicon/favicon.ico?url"; import { LogoSpinner } from "@/components/common/logo-spinner"; import globalStyles from "@/styles/globals.css?url"; +import { AppProviders } from "@/providers"; import type { Route } from "./+types/root"; -import { AppProviders } from "./providers"; +// fonts +import "@fontsource-variable/inter"; +import interVariableWoff2 from "@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?url"; +import "@fontsource/material-symbols-rounded"; +import "@fontsource/ibm-plex-mono"; const APP_TITLE = "Plane | Simple, extensible, open-source project management tool."; const APP_DESCRIPTION = @@ -22,6 +32,13 @@ export const links: LinksFunction = () => [ { rel: "shortcut icon", href: faviconIco }, { rel: "manifest", href: `/site.webmanifest.json` }, { rel: "stylesheet", href: globalStyles }, + { + rel: "preload", + href: interVariableWoff2, + as: "font", + type: "font/woff2", + crossOrigin: "anonymous", + }, ]; export function Layout({ children }: { children: ReactNode }) { @@ -56,7 +73,11 @@ export const meta: Route.MetaFunction = () => [ ]; export default function Root() { - return ; + return ( +
+ +
+ ); } export function HydrateFallback() { @@ -67,11 +88,7 @@ export function HydrateFallback() { ); } -export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { - if (error) { - Sentry.captureException(error); - } - +export function ErrorBoundary({ error: _error }: Route.ErrorBoundaryProps) { return (

Something went wrong.

diff --git a/apps/admin/app/routes.ts b/apps/admin/app/routes.ts index 0f7232439..184bed205 100644 --- a/apps/admin/app/routes.ts +++ b/apps/admin/app/routes.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { index, layout, route } from "@react-router/dev/routes"; import type { RouteConfig } from "@react-router/dev/routes"; diff --git a/apps/admin/ce/components/authentication/index.ts b/apps/admin/ce/components/authentication/index.ts deleted file mode 100644 index d2aa74855..000000000 --- a/apps/admin/ce/components/authentication/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./authentication-modes"; diff --git a/apps/admin/ce/components/common/index.ts b/apps/admin/ce/components/common/index.ts deleted file mode 100644 index c6a1da8b6..000000000 --- a/apps/admin/ce/components/common/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./upgrade-button"; diff --git a/apps/admin/ce/components/common/upgrade-button.tsx b/apps/admin/ce/components/common/upgrade-button.tsx deleted file mode 100644 index 51b6eb6c4..000000000 --- a/apps/admin/ce/components/common/upgrade-button.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from "react"; -// icons -import { SquareArrowOutUpRight } from "lucide-react"; -// plane internal packages -import { getButtonStyling } from "@plane/propel/button"; -import { cn } from "@plane/utils"; - -export function UpgradeButton() { - return ( - - Upgrade - - - ); -} diff --git a/apps/admin/ce/store/root.store.ts b/apps/admin/ce/store/root.store.ts deleted file mode 100644 index 1be816f70..000000000 --- a/apps/admin/ce/store/root.store.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { enableStaticRendering } from "mobx-react"; -// stores -import { CoreRootStore } from "@/store/root.store"; - -enableStaticRendering(typeof window === "undefined"); - -export class RootStore extends CoreRootStore { - constructor() { - super(); - } - - hydrate(initialData: any) { - super.hydrate(initialData); - } - - resetOnSignOut() { - super.resetOnSignOut(); - } -} diff --git a/apps/admin/core/components/authentication/authentication-method-card.tsx b/apps/admin/components/authentication/authentication-method-card.tsx similarity index 61% rename from apps/admin/core/components/authentication/authentication-method-card.tsx rename to apps/admin/components/authentication/authentication-method-card.tsx index c512e24d1..86934bbe7 100644 --- a/apps/admin/core/components/authentication/authentication-method-card.tsx +++ b/apps/admin/components/authentication/authentication-method-card.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + // helpers import { cn } from "@plane/utils"; @@ -16,8 +22,8 @@ export function AuthenticationMethodCard(props: Props) { return (
-
{icon}
+
{icon}
{name}
{description} diff --git a/apps/admin/core/components/authentication/email-config-switch.tsx b/apps/admin/components/authentication/email-config-switch.tsx similarity index 86% rename from apps/admin/core/components/authentication/email-config-switch.tsx rename to apps/admin/components/authentication/email-config-switch.tsx index 2a53eac12..0f304335b 100644 --- a/apps/admin/core/components/authentication/email-config-switch.tsx +++ b/apps/admin/components/authentication/email-config-switch.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import React from "react"; import { observer } from "mobx-react"; // hooks diff --git a/apps/admin/core/components/authentication/gitea-config.tsx b/apps/admin/components/authentication/gitea-config.tsx similarity index 75% rename from apps/admin/core/components/authentication/gitea-config.tsx rename to apps/admin/components/authentication/gitea-config.tsx index 22019979e..ef9d6db15 100644 --- a/apps/admin/core/components/authentication/gitea-config.tsx +++ b/apps/admin/components/authentication/gitea-config.tsx @@ -1,11 +1,17 @@ -import React from "react"; +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; import Link from "next/link"; // icons import { Settings2 } from "lucide-react"; // plane internal packages +import { getButtonStyling } from "@plane/propel/button"; import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; -import { ToggleSwitch, getButtonStyling } from "@plane/ui"; +import { ToggleSwitch } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks import { useInstance } from "@/hooks/store"; @@ -28,7 +34,7 @@ export const GiteaConfiguration = observer(function GiteaConfiguration(props: Pr <> {GiteaConfigured ? (
- + Edit
) : ( - - + + Configure )} diff --git a/apps/admin/core/components/authentication/github-config.tsx b/apps/admin/components/authentication/github-config.tsx similarity index 80% rename from apps/admin/core/components/authentication/github-config.tsx rename to apps/admin/components/authentication/github-config.tsx index b2db3e086..06443bf00 100644 --- a/apps/admin/core/components/authentication/github-config.tsx +++ b/apps/admin/components/authentication/github-config.tsx @@ -1,4 +1,9 @@ -import React from "react"; +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; import Link from "next/link"; // icons @@ -28,7 +33,7 @@ export const GithubConfiguration = observer(function GithubConfiguration(props: <> {isGithubConfigured ? (
- + Edit
) : ( - - + + Configure )} diff --git a/apps/admin/core/components/authentication/gitlab-config.tsx b/apps/admin/components/authentication/gitlab-config.tsx similarity index 80% rename from apps/admin/core/components/authentication/gitlab-config.tsx rename to apps/admin/components/authentication/gitlab-config.tsx index 7e6ee1ddb..dcd3bed2e 100644 --- a/apps/admin/core/components/authentication/gitlab-config.tsx +++ b/apps/admin/components/authentication/gitlab-config.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; import Link from "next/link"; // icons @@ -27,7 +33,7 @@ export const GitlabConfiguration = observer(function GitlabConfiguration(props: <> {isGitlabConfigured ? (
- + Edit
) : ( - - + + Configure )} diff --git a/apps/admin/core/components/authentication/google-config.tsx b/apps/admin/components/authentication/google-config.tsx similarity index 80% rename from apps/admin/core/components/authentication/google-config.tsx rename to apps/admin/components/authentication/google-config.tsx index d31b38dda..556dd5aa6 100644 --- a/apps/admin/core/components/authentication/google-config.tsx +++ b/apps/admin/components/authentication/google-config.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; import Link from "next/link"; // icons @@ -27,7 +33,7 @@ export const GoogleConfiguration = observer(function GoogleConfiguration(props: <> {isGoogleConfigured ? (
- + Edit
) : ( - - + + Configure )} diff --git a/apps/admin/core/components/authentication/password-config-switch.tsx b/apps/admin/components/authentication/password-config-switch.tsx similarity index 86% rename from apps/admin/core/components/authentication/password-config-switch.tsx rename to apps/admin/components/authentication/password-config-switch.tsx index bdc9c4920..1b603980d 100644 --- a/apps/admin/core/components/authentication/password-config-switch.tsx +++ b/apps/admin/components/authentication/password-config-switch.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import React from "react"; import { observer } from "mobx-react"; // hooks diff --git a/apps/admin/components/common/banner.tsx b/apps/admin/components/common/banner.tsx new file mode 100644 index 000000000..1c56afa3c --- /dev/null +++ b/apps/admin/components/common/banner.tsx @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { AlertCircle, CheckCircle2 } from "lucide-react"; + +type TBanner = { + type: "success" | "error"; + message: string; +}; + +export function Banner(props: TBanner) { + const { type, message } = props; + + return ( +
+
+
+ {type === "error" ? ( + + + ) : ( +
+
+

+ {message} +

+
+
+
+ ); +} diff --git a/apps/admin/core/components/common/breadcrumb-link.tsx b/apps/admin/components/common/breadcrumb-link.tsx similarity index 62% rename from apps/admin/core/components/common/breadcrumb-link.tsx rename to apps/admin/components/common/breadcrumb-link.tsx index aa647e220..46d4fd1da 100644 --- a/apps/admin/core/components/common/breadcrumb-link.tsx +++ b/apps/admin/components/common/breadcrumb-link.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import Link from "next/link"; import { Tooltip } from "@plane/propel/tooltip"; @@ -14,19 +20,14 @@ export function BreadcrumbLink(props: Props) {
  • {href ? ( - - {icon && ( -
    {icon}
    - )} -
    {label}
    + + {icon &&
    {icon}
    } +
    {label}
    ) : ( -
    +
    {icon &&
    {icon}
    } -
    {label}
    +
    {label}
    )}
    diff --git a/apps/admin/core/components/common/code-block.tsx b/apps/admin/components/common/code-block.tsx similarity index 51% rename from apps/admin/core/components/common/code-block.tsx rename to apps/admin/components/common/code-block.tsx index ab4645949..02c44be22 100644 --- a/apps/admin/core/components/common/code-block.tsx +++ b/apps/admin/components/common/code-block.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { cn } from "@plane/utils"; type TProps = { @@ -10,9 +16,9 @@ export function CodeBlock({ children, className, darkerShade }: TProps) { return ( -
    +
    @@ -39,26 +45,26 @@ export function ConfirmDiscardModal(props: Props) { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - -
    + +
    - + You have unsaved changes
    -

    +

    Changes you made will be lost if you go back. Do you wish to go back?

    -
    - - + Go back
    diff --git a/apps/admin/core/components/common/controller-input.tsx b/apps/admin/components/common/controller-input.tsx similarity index 81% rename from apps/admin/core/components/common/controller-input.tsx rename to apps/admin/components/common/controller-input.tsx index 4d3534859..3d95b4484 100644 --- a/apps/admin/core/components/common/controller-input.tsx +++ b/apps/admin/components/common/controller-input.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import React, { useState } from "react"; import type { Control } from "react-hook-form"; import { Controller } from "react-hook-form"; @@ -35,7 +41,7 @@ export function ControllerInput(props: Props) { return (
    -

    {label}

    +

    {label}

    setShowPassword(false)} > @@ -69,14 +75,14 @@ export function ControllerInput(props: Props) { ) : ( ))}
    - {description &&

    {description}

    } + {description &&

    {description}

    }
    ); } diff --git a/apps/admin/components/common/controller-switch.tsx b/apps/admin/components/common/controller-switch.tsx new file mode 100644 index 000000000..58681949d --- /dev/null +++ b/apps/admin/components/common/controller-switch.tsx @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Control, FieldPath, FieldValues } from "react-hook-form"; +import { Controller } from "react-hook-form"; +// plane internal packages +import { ToggleSwitch } from "@plane/ui"; + +type Props = { + control: Control; + field: TControllerSwitchFormField; +}; + +export type TControllerSwitchFormField = { + name: FieldPath; + label: string; +}; + +export function ControllerSwitch(props: Props) { + const { + control, + field: { name, label }, + } = props; + + return ( +
    +

    Refresh user attributes from {label} during sign in

    +
    + } + render={({ field: { value, onChange } }) => { + const parsedValue = Number.parseInt(typeof value === "string" ? value : String(value ?? "0"), 10); + const isOn = !Number.isNaN(parsedValue) && parsedValue !== 0; + return onChange(isOn ? "0" : "1")} size="sm" />; + }} + /> +
    +
    + ); +} diff --git a/apps/admin/core/components/common/copy-field.tsx b/apps/admin/components/common/copy-field.tsx similarity index 64% rename from apps/admin/core/components/common/copy-field.tsx rename to apps/admin/components/common/copy-field.tsx index 484cf7454..d161740df 100644 --- a/apps/admin/core/components/common/copy-field.tsx +++ b/apps/admin/components/common/copy-field.tsx @@ -1,7 +1,13 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import React from "react"; // ui -import { Copy } from "lucide-react"; import { Button } from "@plane/propel/button"; +import { CopyIcon } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; type Props = { @@ -22,9 +28,10 @@ export function CopyField(props: Props) { return (
    -

    {label}

    +

    {label}

    -
    {description}
    +
    {description}
    ); } diff --git a/apps/admin/core/components/common/empty-state.tsx b/apps/admin/components/common/empty-state.tsx similarity index 76% rename from apps/admin/core/components/common/empty-state.tsx rename to apps/admin/components/common/empty-state.tsx index f69b04179..0f33f13cc 100644 --- a/apps/admin/core/components/common/empty-state.tsx +++ b/apps/admin/components/common/empty-state.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import React from "react"; import { Button } from "@plane/propel/button"; @@ -19,8 +25,8 @@ export function EmptyState({ title, description, image, primaryButton, secondary
    {image && {primaryButton?.text} -
    {title}
    - {description &&

    {description}

    } +
    {title}
    + {description &&

    {description}

    }
    {primaryButton && ( diff --git a/apps/admin/components/common/header/core.ts b/apps/admin/components/common/header/core.ts new file mode 100644 index 000000000..4cfc8ee71 --- /dev/null +++ b/apps/admin/components/common/header/core.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export const CORE_HEADER_SEGMENT_LABELS: Record = { + general: "General", + ai: "Artificial Intelligence", + email: "Email", + authentication: "Authentication", + image: "Image", + google: "Google", + github: "GitHub", + gitlab: "GitLab", + gitea: "Gitea", + workspace: "Workspace", + create: "Create", +}; diff --git a/apps/admin/components/common/header/extended.ts b/apps/admin/components/common/header/extended.ts new file mode 100644 index 000000000..b4c3e66d3 --- /dev/null +++ b/apps/admin/components/common/header/extended.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export const EXTENDED_HEADER_SEGMENT_LABELS: Record = {}; diff --git a/apps/admin/app/(all)/(dashboard)/header.tsx b/apps/admin/components/common/header/index.tsx similarity index 60% rename from apps/admin/app/(all)/(dashboard)/header.tsx rename to apps/admin/components/common/header/index.tsx index c2ccc6358..48b9ca78e 100644 --- a/apps/admin/app/(all)/(dashboard)/header.tsx +++ b/apps/admin/components/common/header/index.tsx @@ -1,57 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; import { usePathname } from "next/navigation"; import { Menu, Settings } from "lucide-react"; // icons import { Breadcrumbs } from "@plane/ui"; // components -import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { BreadcrumbLink } from "../breadcrumb-link"; // hooks import { useTheme } from "@/hooks/store"; +// local imports +import { CORE_HEADER_SEGMENT_LABELS } from "./core"; +import { EXTENDED_HEADER_SEGMENT_LABELS } from "./extended"; export const HamburgerToggle = observer(function HamburgerToggle() { const { isSidebarCollapsed, toggleSidebar } = useTheme(); return ( -
    toggleSidebar(!isSidebarCollapsed)} > - -
    +
    + ); }); +const HEADER_SEGMENT_LABELS = { + ...CORE_HEADER_SEGMENT_LABELS, + ...EXTENDED_HEADER_SEGMENT_LABELS, +}; + export const AdminHeader = observer(function AdminHeader() { const pathName = usePathname(); - const getHeaderTitle = (pathName: string) => { - switch (pathName) { - case "general": - return "General"; - case "ai": - return "Artificial Intelligence"; - case "email": - return "Email"; - case "authentication": - return "Authentication"; - case "image": - return "Image"; - case "google": - return "Google"; - case "github": - return "GitHub"; - case "gitlab": - return "GitLab"; - case "gitea": - return "Gitea"; - case "workspace": - return "Workspace"; - case "create": - return "Create"; - default: - return pathName.toUpperCase(); - } - }; - // Function to dynamically generate breadcrumb items based on pathname const generateBreadcrumbItems = (pathname: string) => { const pathSegments = pathname.split("/").slice(1); // removing the first empty string. @@ -61,17 +46,17 @@ export const AdminHeader = observer(function AdminHeader() { const breadcrumbItems = pathSegments.map((segment) => { currentUrl += "/" + segment; return { - title: getHeaderTitle(segment), + title: HEADER_SEGMENT_LABELS[segment] ?? segment.toUpperCase(), href: currentUrl, }; }); return breadcrumbItems; }; - const breadcrumbItems = generateBreadcrumbItems(pathName); + const breadcrumbItems = generateBreadcrumbItems(pathName || ""); return ( -
    +
    {breadcrumbItems.length >= 0 && ( @@ -82,7 +67,7 @@ export const AdminHeader = observer(function AdminHeader() { } + icon={} /> } /> diff --git a/apps/admin/core/components/common/logo-spinner.tsx b/apps/admin/components/common/logo-spinner.tsx similarity index 76% rename from apps/admin/core/components/common/logo-spinner.tsx rename to apps/admin/components/common/logo-spinner.tsx index 4d06d2822..b74c38d70 100644 --- a/apps/admin/core/components/common/logo-spinner.tsx +++ b/apps/admin/components/common/logo-spinner.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useTheme } from "next-themes"; import LogoSpinnerDark from "@/app/assets/images/logo-spinner-dark.gif?url"; import LogoSpinnerLight from "@/app/assets/images/logo-spinner-light.gif?url"; diff --git a/apps/admin/core/components/new-user-popup.tsx b/apps/admin/components/common/new-user-popup.tsx similarity index 71% rename from apps/admin/core/components/new-user-popup.tsx rename to apps/admin/components/common/new-user-popup.tsx index bd4c3ee46..30dda8d2d 100644 --- a/apps/admin/core/components/new-user-popup.tsx +++ b/apps/admin/components/common/new-user-popup.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; import Link from "next/link"; import { useTheme as useNextTheme } from "next-themes"; @@ -18,24 +24,24 @@ export const NewUserPopup = observer(function NewUserPopup() { if (!isNewUserPopup) return <>; return ( -
    +
    -
    Create workspace
    -
    +
    Create workspace
    +
    Instance setup done! Welcome to Plane instance portal. Start your journey with by creating your first workspace.
    - + Create workspace -
    -
    +
    { + const { children, header, customHeader, size = "md" } = props; + + return ( +
    + {customHeader ? ( +
    {customHeader}
    + ) : ( + header && ( +
    +
    +
    {header.title}
    +
    {header.description}
    +
    + {header.actions &&
    {header.actions}
    } +
    + ) + )} +
    + {children} +
    +
    + ); +}; diff --git a/apps/admin/core/components/instance/failure.tsx b/apps/admin/components/instance/failure.tsx similarity index 62% rename from apps/admin/core/components/instance/failure.tsx rename to apps/admin/components/instance/failure.tsx index e31633dc9..1f1610fbe 100644 --- a/apps/admin/core/components/instance/failure.tsx +++ b/apps/admin/components/instance/failure.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; import { useTheme } from "next-themes"; import { Button } from "@plane/propel/button"; @@ -18,17 +24,17 @@ export const InstanceFailureView = observer(function InstanceFailureView() { return ( <> -
    -
    -
    +
    +
    +
    Instance failure illustration -

    Unable to fetch instance details.

    -

    +

    Unable to fetch instance details.

    +

    We were unable to fetch the details of the instance. Fret not, it might just be a connectivity issue.

    -
    diff --git a/apps/admin/components/instance/form-header.tsx b/apps/admin/components/instance/form-header.tsx new file mode 100644 index 000000000..652b7d304 --- /dev/null +++ b/apps/admin/components/instance/form-header.tsx @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export function FormHeader({ heading, subHeading }: { heading: string; subHeading: string }) { + return ( +
    + {heading} + {subHeading} +
    + ); +} diff --git a/apps/admin/components/instance/instance-not-ready.tsx b/apps/admin/components/instance/instance-not-ready.tsx new file mode 100644 index 000000000..4fa5e0670 --- /dev/null +++ b/apps/admin/components/instance/instance-not-ready.tsx @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import Link from "next/link"; +import { Button } from "@plane/propel/button"; +// assets +import PlaneTakeOffImage from "@/app/assets/images/plane-takeoff.png?url"; + +export function InstanceNotReady() { + return ( +
    +
    +
    +

    Welcome aboard Plane!

    + Plane Logo +

    Get started by setting up your instance and workspace

    +
    + +
    + + + +
    +
    +
    + ); +} diff --git a/apps/admin/core/components/instance/loading.tsx b/apps/admin/components/instance/loading.tsx similarity index 76% rename from apps/admin/core/components/instance/loading.tsx rename to apps/admin/components/instance/loading.tsx index 2b45deff2..293b44bdc 100644 --- a/apps/admin/core/components/instance/loading.tsx +++ b/apps/admin/components/instance/loading.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useTheme } from "next-themes"; // assets import LogoSpinnerDark from "@/app/assets/images/logo-spinner-dark.gif?url"; diff --git a/apps/admin/core/components/instance/setup-form.tsx b/apps/admin/components/instance/setup-form.tsx similarity index 69% rename from apps/admin/core/components/instance/setup-form.tsx rename to apps/admin/components/instance/setup-form.tsx index ab25b1a3e..74e80db45 100644 --- a/apps/admin/core/components/instance/setup-form.tsx +++ b/apps/admin/components/instance/setup-form.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; // icons @@ -7,11 +13,11 @@ import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants"; import { Button } from "@plane/propel/button"; import { AuthService } from "@plane/services"; import { Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui"; -import { getPasswordStrength } from "@plane/utils"; +import { getPasswordStrength, validatePersonName, validateCompanyName } from "@plane/utils"; // components import { AuthHeader } from "@/app/(all)/(home)/auth-header"; -import { Banner } from "@/components/common/banner"; -import { FormHeader } from "@/components/instance/form-header"; +import { Banner } from "../common/banner"; +import { FormHeader } from "./form-header"; // service initialization const authService = new AuthService(); @@ -54,13 +60,13 @@ const defaultFromData: TFormData = { export function InstanceSetupForm() { // search params const searchParams = useSearchParams(); - const firstNameParam = searchParams.get("first_name") || undefined; - const lastNameParam = searchParams.get("last_name") || undefined; - const companyParam = searchParams.get("company") || undefined; - const emailParam = searchParams.get("email") || undefined; - const isTelemetryEnabledParam = (searchParams.get("is_telemetry_enabled") === "True" ? true : false) || true; - const errorCode = searchParams.get("error_code") || undefined; - const errorMessage = searchParams.get("error_message") || undefined; + const firstNameParam = searchParams?.get("first_name") || undefined; + const lastNameParam = searchParams?.get("last_name") || undefined; + const companyParam = searchParams?.get("company") || undefined; + const emailParam = searchParams?.get("email") || undefined; + const isTelemetryEnabledParam = (searchParams?.get("is_telemetry_enabled") === "True" ? true : false) || true; + const errorCode = searchParams?.get("error_code") || undefined; + const errorMessage = searchParams?.get("error_message") || undefined; // state const [showPassword, setShowPassword] = useState({ password: false, @@ -133,8 +139,8 @@ export function InstanceSetupForm() { return ( <> -
    -
    +
    +
    -
    +
    -
    -
    -
    -
    -
    -
    handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)} checked={formData.is_telemetry_enabled} />
    -
    -
    diff --git a/apps/admin/core/components/workspace/list-item.tsx b/apps/admin/components/workspace/list-item.tsx similarity index 53% rename from apps/admin/core/components/workspace/list-item.tsx rename to apps/admin/components/workspace/list-item.tsx index 01855eec8..9594d7f21 100644 --- a/apps/admin/core/components/workspace/list-item.tsx +++ b/apps/admin/components/workspace/list-item.tsx @@ -1,7 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; -import { ExternalLink } from "lucide-react"; + // plane internal packages import { WEB_BASE_URL } from "@plane/constants"; +import { NewTabIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; import { getFileURL } from "@plane/utils"; // hooks @@ -23,19 +30,19 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace key={workspaceId} href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`} target="_blank" - className="group flex items-center justify-between p-4 gap-2.5 truncate border border-custom-border-200/70 hover:border-custom-border-200 hover:bg-custom-background-90 rounded-md" + className="group flex items-center justify-between gap-2.5 truncate rounded-lg border border-subtle bg-layer-1 p-3 hover:border-subtle-1 hover:bg-layer-1-hover hover:shadow-raised-100" rel="noreferrer" >
    {workspace?.logo_url && workspace.logo_url !== "" ? ( Workspace Logo ) : ( @@ -43,31 +50,31 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace )}
    -
    -

    {workspace.name}

    / +
    +

    {workspace.name}

    / -

    [{workspace.slug}]

    +

    [{workspace.slug}]

    {workspace.owner.email && ( -
    -

    Owned by:

    -

    {workspace.owner.email}

    +
    +

    Owned by:

    +

    {workspace.owner.email}

    )} -
    +
    {workspace.total_projects !== null && ( -

    Total projects:

    -

    {workspace.total_projects}

    +

    Total projects:

    +

    {workspace.total_projects}

    )} {workspace.total_members !== null && ( <> • -

    Total members:

    -

    {workspace.total_members}

    +

    Total members:

    +

    {workspace.total_members}

    )} @@ -75,7 +82,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
    - +
    ); diff --git a/apps/admin/core/components/common/banner.tsx b/apps/admin/core/components/common/banner.tsx deleted file mode 100644 index df0818b34..000000000 --- a/apps/admin/core/components/common/banner.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { AlertCircle, CheckCircle2 } from "lucide-react"; - -type TBanner = { - type: "success" | "error"; - message: string; -}; - -export function Banner(props: TBanner) { - const { type, message } = props; - - return ( -
    -
    -
    - {type === "error" ? ( - - - ) : ( -
    -
    -

    {message}

    -
    -
    -
    - ); -} diff --git a/apps/admin/core/components/instance/form-header.tsx b/apps/admin/core/components/instance/form-header.tsx deleted file mode 100644 index ead66b963..000000000 --- a/apps/admin/core/components/instance/form-header.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export function FormHeader({ heading, subHeading }: { heading: string; subHeading: string }) { - return ( -
    - {heading} - {subHeading} -
    - ); -} diff --git a/apps/admin/core/components/instance/instance-not-ready.tsx b/apps/admin/core/components/instance/instance-not-ready.tsx deleted file mode 100644 index 0473effcd..000000000 --- a/apps/admin/core/components/instance/instance-not-ready.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import Link from "next/link"; -import { Button } from "@plane/propel/button"; -// assets -import PlaneTakeOffImage from "@/app/assets/images/plane-takeoff.png?url"; - -export function InstanceNotReady() { - return ( -
    -
    -
    -

    Welcome aboard Plane!

    - Plane Logo -

    - Get started by setting up your instance and workspace -

    -
    - -
    - - - -
    -
    -
    - ); -} diff --git a/apps/admin/core/hooks/store/index.ts b/apps/admin/core/hooks/store/index.ts deleted file mode 100644 index ed1781299..000000000 --- a/apps/admin/core/hooks/store/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./use-theme"; -export * from "./use-instance"; -export * from "./use-user"; -export * from "./use-workspace"; diff --git a/apps/admin/core/lib/b-progress/index.tsx b/apps/admin/core/lib/b-progress/index.tsx deleted file mode 100644 index 7b531da2b..000000000 --- a/apps/admin/core/lib/b-progress/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from "./AppProgressBar"; diff --git a/apps/admin/core/utils/public-asset.ts b/apps/admin/core/utils/public-asset.ts deleted file mode 100644 index cb0ff5c3b..000000000 --- a/apps/admin/core/utils/public-asset.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/apps/admin/ee/components/authentication/authentication-modes.tsx b/apps/admin/ee/components/authentication/authentication-modes.tsx deleted file mode 100644 index 4e3b05a52..000000000 --- a/apps/admin/ee/components/authentication/authentication-modes.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from "ce/components/authentication/authentication-modes"; diff --git a/apps/admin/ee/components/authentication/index.ts b/apps/admin/ee/components/authentication/index.ts deleted file mode 100644 index d2aa74855..000000000 --- a/apps/admin/ee/components/authentication/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./authentication-modes"; diff --git a/apps/admin/ee/components/common/index.ts b/apps/admin/ee/components/common/index.ts deleted file mode 100644 index 60441ee25..000000000 --- a/apps/admin/ee/components/common/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "ce/components/common"; diff --git a/apps/admin/ee/store/root.store.ts b/apps/admin/ee/store/root.store.ts deleted file mode 100644 index c514c4c25..000000000 --- a/apps/admin/ee/store/root.store.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "ce/store/root.store"; diff --git a/apps/admin/helpers/authentication.ts b/apps/admin/helpers/authentication.ts new file mode 100644 index 000000000..22b5e3b4b --- /dev/null +++ b/apps/admin/helpers/authentication.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { + IFormattedInstanceConfiguration, + TInstanceAuthenticationModes, + TInstanceConfigurationKeys, +} from "@plane/types"; + +/** + * Checks if a given authentication method can be disabled. + * @param configKey - The configuration key to check. + * @param authModes - The authentication modes to check. + * @param formattedConfig - The formatted configuration to check. + * @returns True if the authentication method can be disabled, false otherwise. + */ +export const canDisableAuthMethod = ( + configKey: TInstanceConfigurationKeys, + authModes: TInstanceAuthenticationModes[], + formattedConfig: IFormattedInstanceConfiguration | undefined +): boolean => { + // Count currently enabled methods + const enabledCount = authModes.reduce((count, method) => { + const enabledKey = method.enabledConfigKey; + if (!enabledKey || !formattedConfig) return count; + const isEnabled = Boolean(parseInt(formattedConfig[enabledKey] ?? "0")); + return isEnabled ? count + 1 : count; + }, 0); + + // If trying to disable and only 1 method is enabled, prevent it + const isCurrentlyEnabled = Boolean(parseInt(formattedConfig?.[configKey] ?? "0")); + return !(isCurrentlyEnabled && enabledCount === 1); +}; diff --git a/apps/admin/ce/components/authentication/authentication-modes.tsx b/apps/admin/hooks/oauth/core.tsx similarity index 51% rename from apps/admin/ce/components/authentication/authentication-modes.tsx rename to apps/admin/hooks/oauth/core.tsx index 7b0f658d8..9e6914e41 100644 --- a/apps/admin/ce/components/authentication/authentication-modes.tsx +++ b/apps/admin/hooks/oauth/core.tsx @@ -1,129 +1,92 @@ -import { observer } from "mobx-react"; -import { useTheme } from "next-themes"; +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { KeyRound, Mails } from "lucide-react"; // types import type { + TCoreInstanceAuthenticationModeKeys, TGetBaseAuthenticationModeProps, - TInstanceAuthenticationMethodKeys, TInstanceAuthenticationModes, } from "@plane/types"; -import { resolveGeneralTheme } from "@plane/utils"; -// components +// assets import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url"; import githubLightModeImage from "@/app/assets/logos/github-black.png?url"; import githubDarkModeImage from "@/app/assets/logos/github-white.png?url"; -import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; -import GoogleLogo from "@/app/assets/logos/google-logo.svg?url"; -import OIDCLogo from "@/app/assets/logos/oidc-logo.svg?url"; -import SAMLLogo from "@/app/assets/logos/saml-logo.svg?url"; -import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +import gitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; +import googleLogo from "@/app/assets/logos/google-logo.svg?url"; +// components import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch"; import { GiteaConfiguration } from "@/components/authentication/gitea-config"; import { GithubConfiguration } from "@/components/authentication/github-config"; import { GitlabConfiguration } from "@/components/authentication/gitlab-config"; import { GoogleConfiguration } from "@/components/authentication/google-config"; import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch"; -// plane admin components -import { UpgradeButton } from "@/plane-admin/components/common"; -// assets - -export type TAuthenticationModeProps = { - disabled: boolean; - updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; -}; // Authentication methods -export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({ +export const getCoreAuthenticationModesMap: ( + props: TGetBaseAuthenticationModeProps +) => Record = ({ disabled, updateConfig, resolvedTheme, -}) => [ - { +}) => ({ + "unique-codes": { key: "unique-codes", name: "Unique codes", description: "Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.", - icon: , + icon: , config: , + enabledConfigKey: "ENABLE_MAGIC_LINK_LOGIN", }, - { + "passwords-login": { key: "passwords-login", name: "Passwords", description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.", - icon: , + icon: , config: , + enabledConfigKey: "ENABLE_EMAIL_PASSWORD", }, - { + google: { key: "google", name: "Google", description: "Allow members to log in or sign up for Plane with their Google accounts.", - icon: Google Logo, + icon: Google Logo, config: , + enabledConfigKey: "IS_GOOGLE_ENABLED", }, - { + github: { key: "github", name: "GitHub", description: "Allow members to log in or sign up for Plane with their GitHub accounts.", icon: ( GitHub Logo ), config: , + enabledConfigKey: "IS_GITHUB_ENABLED", }, - { + gitlab: { key: "gitlab", name: "GitLab", description: "Allow members to log in or sign up to plane with their GitLab accounts.", - icon: GitLab Logo, + icon: GitLab Logo, config: , + enabledConfigKey: "IS_GITLAB_ENABLED", }, - { + gitea: { key: "gitea", name: "Gitea", description: "Allow members to log in or sign up to plane with their Gitea accounts.", icon: Gitea Logo, config: , + enabledConfigKey: "IS_GITEA_ENABLED", }, - { - key: "oidc", - name: "OIDC", - description: "Authenticate your users via the OpenID Connect protocol.", - icon: OIDC Logo, - config: , - unavailable: true, - }, - { - key: "saml", - name: "SAML", - description: "Authenticate your users via the Security Assertion Markup Language protocol.", - icon: SAML Logo, - config: , - unavailable: true, - }, -]; - -export const AuthenticationModes = observer(function AuthenticationModes(props: TAuthenticationModeProps) { - const { disabled, updateConfig } = props; - // next-themes - const { resolvedTheme } = useTheme(); - - return ( - <> - {getAuthenticationModes({ disabled, updateConfig, resolvedTheme }).map((method) => ( - - ))} - - ); }); diff --git a/apps/admin/hooks/oauth/index.ts b/apps/admin/hooks/oauth/index.ts new file mode 100644 index 000000000..74c11e33f --- /dev/null +++ b/apps/admin/hooks/oauth/index.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { TInstanceAuthenticationModes } from "@plane/types"; +import { getCoreAuthenticationModesMap } from "./core"; +import type { TGetAuthenticationModeProps } from "./types"; + +export const useAuthenticationModes = (props: TGetAuthenticationModeProps): TInstanceAuthenticationModes[] => { + // derived values + const authenticationModes = getCoreAuthenticationModesMap(props); + + const availableAuthenticationModes: TInstanceAuthenticationModes[] = [ + authenticationModes["unique-codes"], + authenticationModes["passwords-login"], + authenticationModes["google"], + authenticationModes["github"], + authenticationModes["gitlab"], + authenticationModes["gitea"], + ]; + + return availableAuthenticationModes; +}; diff --git a/apps/admin/hooks/oauth/types.ts b/apps/admin/hooks/oauth/types.ts new file mode 100644 index 000000000..3e89ad936 --- /dev/null +++ b/apps/admin/hooks/oauth/types.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; + +export type TGetAuthenticationModeProps = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; + resolvedTheme: string | undefined; +}; diff --git a/apps/admin/hooks/store/index.ts b/apps/admin/hooks/store/index.ts new file mode 100644 index 000000000..3b8df72f6 --- /dev/null +++ b/apps/admin/hooks/store/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./use-theme"; +export * from "./use-instance"; +export * from "./use-user"; +export * from "./use-workspace"; diff --git a/apps/admin/core/hooks/store/use-instance.tsx b/apps/admin/hooks/store/use-instance.tsx similarity index 60% rename from apps/admin/core/hooks/store/use-instance.tsx rename to apps/admin/hooks/store/use-instance.tsx index 5917df3fa..4ae991139 100644 --- a/apps/admin/core/hooks/store/use-instance.tsx +++ b/apps/admin/hooks/store/use-instance.tsx @@ -1,6 +1,12 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useContext } from "react"; // store -import { StoreContext } from "@/app/(all)/store.provider"; +import { StoreContext } from "@/providers/store.provider"; import type { IInstanceStore } from "@/store/instance.store"; export const useInstance = (): IInstanceStore => { diff --git a/apps/admin/core/hooks/store/use-theme.tsx b/apps/admin/hooks/store/use-theme.tsx similarity index 59% rename from apps/admin/core/hooks/store/use-theme.tsx rename to apps/admin/hooks/store/use-theme.tsx index d5a1e820e..2348c2e43 100644 --- a/apps/admin/core/hooks/store/use-theme.tsx +++ b/apps/admin/hooks/store/use-theme.tsx @@ -1,6 +1,12 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useContext } from "react"; // store -import { StoreContext } from "@/app/(all)/store.provider"; +import { StoreContext } from "@/providers/store.provider"; import type { IThemeStore } from "@/store/theme.store"; export const useTheme = (): IThemeStore => { diff --git a/apps/admin/core/hooks/store/use-user.tsx b/apps/admin/hooks/store/use-user.tsx similarity index 58% rename from apps/admin/core/hooks/store/use-user.tsx rename to apps/admin/hooks/store/use-user.tsx index 56b988eb8..5eba488d9 100644 --- a/apps/admin/core/hooks/store/use-user.tsx +++ b/apps/admin/hooks/store/use-user.tsx @@ -1,6 +1,12 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useContext } from "react"; // store -import { StoreContext } from "@/app/(all)/store.provider"; +import { StoreContext } from "@/providers/store.provider"; import type { IUserStore } from "@/store/user.store"; export const useUser = (): IUserStore => { diff --git a/apps/admin/core/hooks/store/use-workspace.tsx b/apps/admin/hooks/store/use-workspace.tsx similarity index 60% rename from apps/admin/core/hooks/store/use-workspace.tsx rename to apps/admin/hooks/store/use-workspace.tsx index c4578c917..42a2bf7f7 100644 --- a/apps/admin/core/hooks/store/use-workspace.tsx +++ b/apps/admin/hooks/store/use-workspace.tsx @@ -1,6 +1,12 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useContext } from "react"; // store -import { StoreContext } from "@/app/(all)/store.provider"; +import { StoreContext } from "@/providers/store.provider"; import type { IWorkspaceStore } from "@/store/workspace.store"; export const useWorkspace = (): IWorkspaceStore => { diff --git a/apps/admin/hooks/use-sidebar-menu/core.ts b/apps/admin/hooks/use-sidebar-menu/core.ts new file mode 100644 index 000000000..9d4e449b3 --- /dev/null +++ b/apps/admin/hooks/use-sidebar-menu/core.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Image, BrainCog, Cog, Mail } from "lucide-react"; +// plane imports +import { LockIcon, WorkspaceIcon } from "@plane/propel/icons"; +// types +import type { TSidebarMenuItem } from "./types"; + +export type TCoreSidebarMenuKey = "general" | "email" | "workspace" | "authentication" | "ai" | "image"; + +export const coreSidebarMenuLinks: Record = { + general: { + Icon: Cog, + name: "General", + description: "Identify your instances and get key details.", + href: `/general/`, + }, + email: { + Icon: Mail, + name: "Email", + description: "Configure your SMTP controls.", + href: `/email/`, + }, + workspace: { + Icon: WorkspaceIcon, + name: "Workspaces", + description: "Manage all workspaces on this instance.", + href: `/workspace/`, + }, + authentication: { + Icon: LockIcon, + name: "Authentication", + description: "Configure authentication modes.", + href: `/authentication/`, + }, + ai: { + Icon: BrainCog, + name: "Artificial intelligence", + description: "Configure your OpenAI creds.", + href: `/ai/`, + }, + image: { + Icon: Image, + name: "Images in Plane", + description: "Allow third-party image libraries.", + href: `/image/`, + }, +}; diff --git a/apps/admin/hooks/use-sidebar-menu/index.ts b/apps/admin/hooks/use-sidebar-menu/index.ts new file mode 100644 index 000000000..cfc76f47e --- /dev/null +++ b/apps/admin/hooks/use-sidebar-menu/index.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// local imports +import { coreSidebarMenuLinks } from "./core"; +import type { TSidebarMenuItem } from "./types"; + +export function useSidebarMenu(): TSidebarMenuItem[] { + return [ + coreSidebarMenuLinks.general, + coreSidebarMenuLinks.email, + coreSidebarMenuLinks.authentication, + coreSidebarMenuLinks.workspace, + coreSidebarMenuLinks.ai, + coreSidebarMenuLinks.image, + ]; +} diff --git a/apps/admin/hooks/use-sidebar-menu/types.ts b/apps/admin/hooks/use-sidebar-menu/types.ts new file mode 100644 index 000000000..dfe531bb2 --- /dev/null +++ b/apps/admin/hooks/use-sidebar-menu/types.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { LucideIcon } from "lucide-react"; + +export type TSidebarMenuItem = { + Icon: LucideIcon | React.ComponentType<{ className?: string }>; + name: string; + description: string; + href: string; +}; diff --git a/apps/space/core/lib/b-progress/AppProgressBar.tsx b/apps/admin/lib/b-progress/AppProgressBar.tsx similarity index 95% rename from apps/space/core/lib/b-progress/AppProgressBar.tsx rename to apps/admin/lib/b-progress/AppProgressBar.tsx index 7ad93fc11..e4362581d 100644 --- a/apps/space/core/lib/b-progress/AppProgressBar.tsx +++ b/apps/admin/lib/b-progress/AppProgressBar.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useEffect, useRef } from "react"; import { BProgress } from "@bprogress/core"; import { useNavigation } from "react-router"; diff --git a/apps/admin/lib/b-progress/index.tsx b/apps/admin/lib/b-progress/index.tsx new file mode 100644 index 000000000..592017255 --- /dev/null +++ b/apps/admin/lib/b-progress/index.tsx @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./AppProgressBar"; diff --git a/apps/admin/nginx/nginx.conf b/apps/admin/nginx/nginx.conf index 243aebff5..0fd4a192a 100644 --- a/apps/admin/nginx/nginx.conf +++ b/apps/admin/nginx/nginx.conf @@ -20,6 +20,12 @@ http { server { listen 3000; + # Security headers + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-XSS-Protection "1; mode=block" always; + location / { root /usr/share/nginx/html; index index.html index.htm; diff --git a/apps/admin/package.json b/apps/admin/package.json index 573a3ddfe..8a492643a 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,9 +1,9 @@ { "name": "admin", - "description": "Admin UI for Plane", - "version": "1.2.3", - "license": "AGPL-3.0", + "version": "1.3.0", "private": true, + "description": "Admin UI for Plane", + "license": "AGPL-3.0", "type": "module", "scripts": { "dev": "react-router dev --port 3001", @@ -11,14 +11,17 @@ "preview": "react-router build && serve -s build/client -l 3001", "start": "serve -s build/client -l 3001", "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist && rm -rf build", - "check:lint": "eslint . --max-warnings=485", + "check:lint": "oxlint --max-warnings=759 .", "check:types": "react-router typegen && tsc --noEmit", - "check:format": "prettier --check .", - "fix:lint": "eslint . --fix --max-warnings=485", - "fix:format": "prettier --write ." + "check:format": "oxfmt --check .", + "fix:lint": "oxlint --fix .", + "fix:format": "oxfmt ." }, "dependencies": { "@bprogress/core": "catalog:", + "@fontsource-variable/inter": "5.2.8", + "@fontsource/ibm-plex-mono": "5.2.7", + "@fontsource/material-symbols-rounded": "5.2.30", "@headlessui/react": "^1.7.19", "@plane/constants": "workspace:*", "@plane/hooks": "workspace:*", @@ -28,7 +31,6 @@ "@plane/ui": "workspace:*", "@plane/utils": "workspace:*", "@react-router/node": "catalog:", - "@sentry/react-router": "catalog:", "@tanstack/react-virtual": "^3.13.12", "@tanstack/virtual-core": "^3.13.12", "axios": "catalog:", @@ -42,13 +44,11 @@ "react-dom": "catalog:", "react-hook-form": "7.51.5", "react-router": "catalog:", - "react-router-dom": "catalog:", "serve": "14.2.5", "swr": "catalog:", "uuid": "catalog:" }, "devDependencies": { - "@dotenvx/dotenvx": "catalog:", "@plane/tailwind-config": "workspace:*", "@plane/typescript-config": "workspace:*", "@react-router/dev": "catalog:", @@ -56,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" diff --git a/apps/admin/postcss.config.cjs b/apps/admin/postcss.config.cjs deleted file mode 100644 index 8a677108f..000000000 --- a/apps/admin/postcss.config.cjs +++ /dev/null @@ -1 +0,0 @@ -module.exports = require("@plane/tailwind-config/postcss.config.js"); diff --git a/apps/admin/postcss.config.js b/apps/admin/postcss.config.js new file mode 100644 index 000000000..3ad28f15f --- /dev/null +++ b/apps/admin/postcss.config.js @@ -0,0 +1,3 @@ +import postcssConfig from "@plane/tailwind-config/postcss.config.js"; + +export default postcssConfig; diff --git a/apps/admin/app/providers.tsx b/apps/admin/providers/core.tsx similarity index 61% rename from apps/admin/app/providers.tsx rename to apps/admin/providers/core.tsx index 0406cec09..3b22c7087 100644 --- a/apps/admin/app/providers.tsx +++ b/apps/admin/providers/core.tsx @@ -1,10 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { ThemeProvider } from "next-themes"; import { SWRConfig } from "swr"; import { AppProgressBar } from "@/lib/b-progress"; -import { InstanceProvider } from "./(all)/instance.provider"; -import { StoreProvider } from "./(all)/store.provider"; -import { ToastWithTheme } from "./(all)/toast"; -import { UserProvider } from "./(all)/user.provider"; +// local imports +import { ToastWithTheme } from "./toast"; +import { StoreProvider } from "./store.provider"; +import { InstanceProvider } from "./instance.provider"; +import { UserProvider } from "./user.provider"; const DEFAULT_SWR_CONFIG = { refreshWhenHidden: false, @@ -15,7 +22,7 @@ const DEFAULT_SWR_CONFIG = { errorRetryCount: 3, }; -export function AppProviders({ children }: { children: React.ReactNode }) { +export function CoreProviders({ children }: { children: React.ReactNode }) { return ( diff --git a/apps/admin/providers/extended.tsx b/apps/admin/providers/extended.tsx new file mode 100644 index 000000000..72023a79b --- /dev/null +++ b/apps/admin/providers/extended.tsx @@ -0,0 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export function ExtendedProviders({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/apps/admin/providers/index.tsx b/apps/admin/providers/index.tsx new file mode 100644 index 000000000..2ecd419b2 --- /dev/null +++ b/apps/admin/providers/index.tsx @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { CoreProviders } from "./core"; +import { ExtendedProviders } from "./extended"; + +export function AppProviders({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/admin/app/(all)/instance.provider.tsx b/apps/admin/providers/instance.provider.tsx similarity index 77% rename from apps/admin/app/(all)/instance.provider.tsx rename to apps/admin/providers/instance.provider.tsx index 5dcd3b6f5..50e669621 100644 --- a/apps/admin/app/(all)/instance.provider.tsx +++ b/apps/admin/providers/instance.provider.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; import useSWR from "swr"; // hooks diff --git a/apps/admin/app/(all)/store.provider.tsx b/apps/admin/providers/store.provider.tsx similarity index 84% rename from apps/admin/app/(all)/store.provider.tsx rename to apps/admin/providers/store.provider.tsx index 49e341b72..9ddb53b6b 100644 --- a/apps/admin/app/(all)/store.provider.tsx +++ b/apps/admin/providers/store.provider.tsx @@ -1,6 +1,12 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { createContext } from "react"; // plane admin store -import { RootStore } from "@/plane-admin/store/root.store"; +import { RootStore } from "../store/root.store"; let rootStore = new RootStore(); diff --git a/apps/admin/app/(all)/toast.tsx b/apps/admin/providers/toast.tsx similarity index 64% rename from apps/admin/app/(all)/toast.tsx rename to apps/admin/providers/toast.tsx index 1e7e3a11e..541678efa 100644 --- a/apps/admin/app/(all)/toast.tsx +++ b/apps/admin/providers/toast.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useTheme } from "next-themes"; import { Toast } from "@plane/propel/toast"; import { resolveGeneralTheme } from "@plane/utils"; diff --git a/apps/admin/app/(all)/user.provider.tsx b/apps/admin/providers/user.provider.tsx similarity index 86% rename from apps/admin/app/(all)/user.provider.tsx rename to apps/admin/providers/user.provider.tsx index 04242abc9..3a840c186 100644 --- a/apps/admin/app/(all)/user.provider.tsx +++ b/apps/admin/providers/user.provider.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useEffect } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; diff --git a/apps/admin/core/store/instance.store.ts b/apps/admin/store/instance.store.ts similarity index 96% rename from apps/admin/core/store/instance.store.ts rename to apps/admin/store/instance.store.ts index ec8922920..ed92beae1 100644 --- a/apps/admin/core/store/instance.store.ts +++ b/apps/admin/store/instance.store.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { set } from "lodash-es"; import { observable, action, computed, makeObservable, runInAction } from "mobx"; // plane internal packages @@ -13,7 +19,7 @@ import type { IInstanceConfig, } from "@plane/types"; // root store -import type { CoreRootStore } from "@/store/root.store"; +import type { RootStore } from "@/store/root.store"; export interface IInstanceStore { // issues @@ -47,7 +53,7 @@ export class InstanceStore implements IInstanceStore { // service instanceService; - constructor(private store: CoreRootStore) { + constructor(private store: RootStore) { makeObservable(this, { // observable isLoading: observable.ref, diff --git a/apps/admin/core/store/root.store.ts b/apps/admin/store/root.store.ts similarity index 87% rename from apps/admin/core/store/root.store.ts rename to apps/admin/store/root.store.ts index 68d11885b..42f0a63e5 100644 --- a/apps/admin/core/store/root.store.ts +++ b/apps/admin/store/root.store.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { enableStaticRendering } from "mobx-react"; // stores import type { IInstanceStore } from "./instance.store"; @@ -11,7 +17,7 @@ import { WorkspaceStore } from "./workspace.store"; enableStaticRendering(typeof window === "undefined"); -export abstract class CoreRootStore { +export class RootStore { theme: IThemeStore; instance: IInstanceStore; user: IUserStore; diff --git a/apps/admin/core/store/theme.store.ts b/apps/admin/store/theme.store.ts similarity index 88% rename from apps/admin/core/store/theme.store.ts rename to apps/admin/store/theme.store.ts index 4512facd2..bb663804f 100644 --- a/apps/admin/core/store/theme.store.ts +++ b/apps/admin/store/theme.store.ts @@ -1,6 +1,12 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { action, observable, makeObservable } from "mobx"; // root store -import type { CoreRootStore } from "@/store/root.store"; +import type { RootStore } from "./root.store"; type TTheme = "dark" | "light"; export interface IThemeStore { @@ -21,7 +27,7 @@ export class ThemeStore implements IThemeStore { isSidebarCollapsed: boolean | undefined = undefined; theme: string | undefined = undefined; - constructor(private store: CoreRootStore) { + constructor(private store: RootStore) { makeObservable(this, { // observables isNewUserPopup: observable.ref, diff --git a/apps/admin/core/store/user.store.ts b/apps/admin/store/user.store.ts similarity index 91% rename from apps/admin/core/store/user.store.ts rename to apps/admin/store/user.store.ts index 1187355a0..c218eec0f 100644 --- a/apps/admin/core/store/user.store.ts +++ b/apps/admin/store/user.store.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { action, observable, runInAction, makeObservable } from "mobx"; // plane internal packages import type { TUserStatus } from "@plane/constants"; @@ -5,7 +11,7 @@ import { EUserStatus } from "@plane/constants"; import { AuthService, UserService } from "@plane/services"; import type { IUser } from "@plane/types"; // root store -import type { CoreRootStore } from "@/store/root.store"; +import type { RootStore } from "@/store/root.store"; export interface IUserStore { // observables @@ -30,7 +36,7 @@ export class UserStore implements IUserStore { userService; authService; - constructor(private store: CoreRootStore) { + constructor(private store: RootStore) { makeObservable(this, { // observables isLoading: observable.ref, diff --git a/apps/admin/core/store/workspace.store.ts b/apps/admin/store/workspace.store.ts similarity index 94% rename from apps/admin/core/store/workspace.store.ts rename to apps/admin/store/workspace.store.ts index f9203ed40..7dbfaa1af 100644 --- a/apps/admin/core/store/workspace.store.ts +++ b/apps/admin/store/workspace.store.ts @@ -1,10 +1,16 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { set } from "lodash-es"; import { action, observable, runInAction, makeObservable, computed } from "mobx"; // plane imports import { InstanceWorkspaceService } from "@plane/services"; import type { IWorkspace, TLoader, TPaginationInfo } from "@plane/types"; // root store -import type { CoreRootStore } from "@/store/root.store"; +import type { RootStore } from "@/store/root.store"; export interface IWorkspaceStore { // observables @@ -31,7 +37,7 @@ export class WorkspaceStore implements IWorkspaceStore { // services instanceWorkspaceService; - constructor(private store: CoreRootStore) { + constructor(private store: RootStore) { makeObservable(this, { // observables loader: observable, diff --git a/apps/admin/styles/globals.css b/apps/admin/styles/globals.css index 1b88a170e..7f7f2483a 100644 --- a/apps/admin/styles/globals.css +++ b/apps/admin/styles/globals.css @@ -1,373 +1,4 @@ -@import "@plane/propel/styles/fonts.css"; - -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer components { - .text-1\.5xl { - font-size: 1.375rem; - line-height: 1.875rem; - } - - .text-2\.5xl { - font-size: 1.75rem; - line-height: 2.25rem; - } -} - -@layer base { - html { - font-family: "Inter", sans-serif; - } - - :root { - color-scheme: light !important; - - --color-primary-10: 229, 243, 250; - --color-primary-20: 216, 237, 248; - --color-primary-30: 199, 229, 244; - --color-primary-40: 169, 214, 239; - --color-primary-50: 144, 202, 234; - --color-primary-60: 109, 186, 227; - --color-primary-70: 75, 170, 221; - --color-primary-80: 41, 154, 214; - --color-primary-90: 34, 129, 180; - --color-primary-100: 0, 99, 153; - --color-primary-200: 0, 92, 143; - --color-primary-300: 0, 86, 133; - --color-primary-400: 0, 77, 117; - --color-primary-500: 0, 66, 102; - --color-primary-600: 0, 53, 82; - --color-primary-700: 0, 43, 66; - --color-primary-800: 0, 33, 51; - --color-primary-900: 0, 23, 36; - - --color-background-100: 255, 255, 255; /* primary bg */ - --color-background-90: 247, 247, 247; /* secondary bg */ - --color-background-80: 232, 232, 232; /* tertiary bg */ - - --color-text-100: 23, 23, 23; /* primary text */ - --color-text-200: 58, 58, 58; /* secondary text */ - --color-text-300: 82, 82, 82; /* tertiary text */ - --color-text-400: 163, 163, 163; /* placeholder text */ - - --color-scrollbar: 163, 163, 163; /* scrollbar thumb */ - - --color-border-100: 245, 245, 245; /* subtle border= 1 */ - --color-border-200: 229, 229, 229; /* subtle border- 2 */ - --color-border-300: 212, 212, 212; /* strong border- 1 */ - --color-border-400: 185, 185, 185; /* strong border- 2 */ - - --color-shadow-2xs: - 0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06), - 0px 1px 2px 0px rgba(23, 23, 23, 0.14); - --color-shadow-xs: - 0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12), - 0px 1px 8px -1px rgba(16, 24, 40, 0.1); - --color-shadow-sm: - 0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02), 0px 1px 12px 0px rgba(0, 0, 0, 0.12); - --color-shadow-rg: - 0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08), - 0px 1px 12px 0px rgba(16, 24, 40, 0.04); - --color-shadow-md: - 0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), - 0px 1px 16px 0px rgba(16, 24, 40, 0.12); - --color-shadow-lg: - 0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12), - 0px 1px 24px 0px rgba(16, 24, 40, 0.12); - --color-shadow-xl: - 0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16), - 0px 0px 52px 0px rgba(16, 24, 40, 0.16); - --color-shadow-2xl: - 0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12), - 0px 1px 32px 0px rgba(16, 24, 40, 0.12); - --color-shadow-3xl: - 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12), - 0px 1px 48px 0px rgba(16, 24, 40, 0.12); - --color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), 0px 12px 32px -16px rgba(0, 0, 0, 0.05); - - --color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */ - --color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */ - --color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */ - - --color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */ - --color-sidebar-text-200: var(--color-text-200); /* secondary sidebar text */ - --color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */ - --color-sidebar-text-400: var(--color-text-400); /* sidebar placeholder text */ - - --color-sidebar-border-100: var(--color-border-100); /* subtle sidebar border= 1 */ - --color-sidebar-border-200: var(--color-border-100); /* subtle sidebar border- 2 */ - --color-sidebar-border-300: var(--color-border-100); /* strong sidebar border- 1 */ - --color-sidebar-border-400: var(--color-border-100); /* strong sidebar border- 2 */ - - --color-sidebar-shadow-2xs: var(--color-shadow-2xs); - --color-sidebar-shadow-xs: var(--color-shadow-xs); - --color-sidebar-shadow-sm: var(--color-shadow-sm); - --color-sidebar-shadow-rg: var(--color-shadow-rg); - --color-sidebar-shadow-md: var(--color-shadow-md); - --color-sidebar-shadow-lg: var(--color-shadow-lg); - --color-sidebar-shadow-xl: var(--color-shadow-xl); - --color-sidebar-shadow-2xl: var(--color-shadow-2xl); - --color-sidebar-shadow-3xl: var(--color-shadow-3xl); - --color-sidebar-shadow-4xl: var(--color-shadow-4xl); - - /* toast theme */ - --color-toast-success-text: 178, 221, 181; - --color-toast-error-text: 206, 44, 49; - --color-toast-warning-text: 255, 186, 24; - --color-toast-info-text: 141, 164, 239; - --color-toast-loading-text: 255, 255, 255; - --color-toast-secondary-text: 185, 187, 198; - --color-toast-tertiary-text: 139, 141, 152; - - --color-toast-success-background: 46, 46, 46; - --color-toast-error-background: 46, 46, 46; - --color-toast-warning-background: 46, 46, 46; - --color-toast-info-background: 46, 46, 46; - --color-toast-loading-background: 46, 46, 46; - - --color-toast-success-border: 42, 126, 59; - --color-toast-error-border: 100, 23, 35; - --color-toast-warning-border: 79, 52, 34; - --color-toast-info-border: 58, 91, 199; - --color-toast-loading-border: 96, 100, 108; - } - - [data-theme="light"], - [data-theme="light-contrast"] { - color-scheme: light !important; - - --color-background-100: 255, 255, 255; /* primary bg */ - --color-background-90: 247, 247, 247; /* secondary bg */ - --color-background-80: 232, 232, 232; /* tertiary bg */ - } - - [data-theme="light"] { - --color-text-100: 23, 23, 23; /* primary text */ - --color-text-200: 58, 58, 58; /* secondary text */ - --color-text-300: 82, 82, 82; /* tertiary text */ - --color-text-400: 163, 163, 163; /* placeholder text */ - - --color-scrollbar: 163, 163, 163; /* scrollbar thumb */ - - --color-border-100: 245, 245, 245; /* subtle border= 1 */ - --color-border-200: 229, 229, 229; /* subtle border- 2 */ - --color-border-300: 212, 212, 212; /* strong border- 1 */ - --color-border-400: 185, 185, 185; /* strong border- 2 */ - - /* toast theme */ - --color-toast-success-text: 62, 155, 79; - --color-toast-error-text: 220, 62, 66; - --color-toast-warning-text: 255, 186, 24; - --color-toast-info-text: 51, 88, 212; - --color-toast-loading-text: 28, 32, 36; - --color-toast-secondary-text: 128, 131, 141; - --color-toast-tertiary-text: 96, 100, 108; - - --color-toast-success-background: 253, 253, 254; - --color-toast-error-background: 255, 252, 252; - --color-toast-warning-background: 254, 253, 251; - --color-toast-info-background: 253, 253, 254; - --color-toast-loading-background: 253, 253, 254; - - --color-toast-success-border: 218, 241, 219; - --color-toast-error-border: 255, 219, 220; - --color-toast-warning-border: 255, 247, 194; - --color-toast-info-border: 210, 222, 255; - --color-toast-loading-border: 224, 225, 230; - } - - [data-theme="light-contrast"] { - --color-text-100: 11, 11, 11; /* primary text */ - --color-text-200: 38, 38, 38; /* secondary text */ - --color-text-300: 58, 58, 58; /* tertiary text */ - --color-text-400: 115, 115, 115; /* placeholder text */ - - --color-scrollbar: 115, 115, 115; /* scrollbar thumb */ - - --color-border-100: 34, 34, 34; /* subtle border= 1 */ - --color-border-200: 38, 38, 38; /* subtle border- 2 */ - --color-border-300: 46, 46, 46; /* strong border- 1 */ - --color-border-400: 58, 58, 58; /* strong border- 2 */ - } - - [data-theme="dark"], - [data-theme="dark-contrast"] { - color-scheme: dark !important; - - --color-primary-10: 8, 31, 43; - --color-primary-20: 10, 37, 51; - --color-primary-30: 13, 49, 69; - --color-primary-40: 16, 58, 81; - --color-primary-50: 18, 68, 94; - --color-primary-60: 23, 86, 120; - --color-primary-70: 28, 104, 146; - --color-primary-80: 31, 116, 163; - --color-primary-90: 34, 129, 180; - --color-primary-100: 40, 146, 204; - --color-primary-200: 41, 154, 214; - --color-primary-300: 75, 170, 221; - --color-primary-400: 109, 186, 227; - --color-primary-500: 144, 202, 234; - --color-primary-600: 169, 214, 239; - --color-primary-700: 199, 229, 244; - --color-primary-800: 216, 237, 248; - --color-primary-900: 229, 243, 250; - - --color-background-100: 25, 25, 25; /* primary bg */ - --color-background-90: 32, 32, 32; /* secondary bg */ - --color-background-80: 44, 44, 44; /* tertiary bg */ - - --color-shadow-2xs: 0px 0px 1px 0px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.5); - --color-shadow-xs: 0px 0px 2px 0px rgba(0, 0, 0, 0.2), 0px 2px 4px 0px rgba(0, 0, 0, 0.5); - --color-shadow-sm: 0px 0px 4px 0px rgba(0, 0, 0, 0.2), 0px 2px 6px 0px rgba(0, 0, 0, 0.5); - --color-shadow-rg: 0px 0px 6px 0px rgba(0, 0, 0, 0.2), 0px 4px 6px 0px rgba(0, 0, 0, 0.5); - --color-shadow-md: 0px 2px 8px 0px rgba(0, 0, 0, 0.2), 0px 4px 8px 0px rgba(0, 0, 0, 0.5); - --color-shadow-lg: 0px 4px 12px 0px rgba(0, 0, 0, 0.25), 0px 4px 10px 0px rgba(0, 0, 0, 0.55); - --color-shadow-xl: 0px 0px 14px 0px rgba(0, 0, 0, 0.25), 0px 6px 10px 0px rgba(0, 0, 0, 0.55); - --color-shadow-2xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.25), 0px 8px 12px 0px rgba(0, 0, 0, 0.6); - --color-shadow-3xl: 0px 4px 24px 0px rgba(0, 0, 0, 0.3), 0px 12px 40px 0px rgba(0, 0, 0, 0.65); - } - - [data-theme="dark"] { - --color-text-100: 229, 229, 229; /* primary text */ - --color-text-200: 163, 163, 163; /* secondary text */ - --color-text-300: 115, 115, 115; /* tertiary text */ - --color-text-400: 82, 82, 82; /* placeholder text */ - - --color-scrollbar: 82, 82, 82; /* scrollbar thumb */ - - --color-border-100: 34, 34, 34; /* subtle border= 1 */ - --color-border-200: 38, 38, 38; /* subtle border- 2 */ - --color-border-300: 46, 46, 46; /* strong border- 1 */ - --color-border-400: 58, 58, 58; /* strong border- 2 */ - } - - [data-theme="dark-contrast"] { - --color-text-100: 250, 250, 250; /* primary text */ - --color-text-200: 241, 241, 241; /* secondary text */ - --color-text-300: 212, 212, 212; /* tertiary text */ - --color-text-400: 115, 115, 115; /* placeholder text */ - - --color-scrollbar: 115, 115, 115; /* scrollbar thumb */ - - --color-border-100: 245, 245, 245; /* subtle border= 1 */ - --color-border-200: 229, 229, 229; /* subtle border- 2 */ - --color-border-300: 212, 212, 212; /* strong border- 1 */ - --color-border-400: 185, 185, 185; /* strong border- 2 */ - } - - [data-theme="light"], - [data-theme="dark"], - [data-theme="light-contrast"], - [data-theme="dark-contrast"] { - --color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */ - --color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */ - --color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */ - - --color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */ - --color-sidebar-text-200: var(--color-text-200); /* secondary sidebar text */ - --color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */ - --color-sidebar-text-400: var(--color-text-400); /* sidebar placeholder text */ - - --color-sidebar-border-100: var(--color-border-100); /* subtle sidebar border= 1 */ - --color-sidebar-border-200: var(--color-border-200); /* subtle sidebar border- 2 */ - --color-sidebar-border-300: var(--color-border-300); /* strong sidebar border- 1 */ - --color-sidebar-border-400: var(--color-border-400); /* strong sidebar border- 2 */ - } -} - -* { - margin: 0; - padding: 0; - box-sizing: border-box; - -webkit-text-size-adjust: 100%; - -ms-text-size-adjust: 100%; - font-variant-ligatures: none; - -webkit-font-variant-ligatures: none; - text-rendering: optimizeLegibility; - -moz-osx-font-smoothing: grayscale; - -webkit-font-smoothing: antialiased; -} - -body { - color: rgba(var(--color-text-100)); -} - -/* scrollbar style */ -::-webkit-scrollbar { - display: none; -} - -@-moz-document url-prefix() { - * { - scrollbar-width: none; - } - .vertical-scrollbar, - .horizontal-scrollbar { - scrollbar-width: initial; - scrollbar-color: rgba(96, 100, 108, 0.1) transparent; - } - .vertical-scrollbar:hover, - .horizontal-scrollbar:hover { - scrollbar-color: rgba(96, 100, 108, 0.25) transparent; - } - .vertical-scrollbar:active, - .horizontal-scrollbar:active { - scrollbar-color: rgba(96, 100, 108, 0.7) transparent; - } -} - -.vertical-scrollbar { - overflow-y: auto; -} -.horizontal-scrollbar { - overflow-x: auto; -} -.vertical-scrollbar::-webkit-scrollbar, -.horizontal-scrollbar::-webkit-scrollbar { - display: block; -} -.vertical-scrollbar::-webkit-scrollbar-track, -.horizontal-scrollbar::-webkit-scrollbar-track { - background-color: transparent; - border-radius: 9999px; -} -.vertical-scrollbar::-webkit-scrollbar-thumb, -.horizontal-scrollbar::-webkit-scrollbar-thumb { - background-clip: padding-box; - background-color: rgba(96, 100, 108, 0.1); - border-radius: 9999px; -} -.vertical-scrollbar:hover::-webkit-scrollbar-thumb, -.horizontal-scrollbar:hover::-webkit-scrollbar-thumb { - background-color: rgba(96, 100, 108, 0.25); -} -.vertical-scrollbar::-webkit-scrollbar-thumb:hover, -.horizontal-scrollbar::-webkit-scrollbar-thumb:hover { - background-color: rgba(96, 100, 108, 0.5); -} -.vertical-scrollbar::-webkit-scrollbar-thumb:active, -.horizontal-scrollbar::-webkit-scrollbar-thumb:active { - background-color: rgba(96, 100, 108, 0.7); -} -.vertical-scrollbar::-webkit-scrollbar-corner, -.horizontal-scrollbar::-webkit-scrollbar-corner { - background-color: transparent; -} -.vertical-scrollbar-margin-top-md::-webkit-scrollbar-track { - margin-top: 44px; -} - -/* scrollbar xs size */ -.scrollbar-xs::-webkit-scrollbar { - height: 10px; - width: 10px; -} -.scrollbar-xs::-webkit-scrollbar-thumb { - border: 3px solid rgba(0, 0, 0, 0); -} +@import "@plane/tailwind-config/index.css"; .shadow-custom { box-shadow: 2px 2px 8px 2px rgba(234, 231, 250, 0.3); /* Convert #EAE7FA4D to rgba */ @@ -377,48 +8,15 @@ body { @apply backdrop-filter blur-[9px]; } -/* scrollbar sm size */ -.scrollbar-sm::-webkit-scrollbar { - height: 12px; - width: 12px; -} -.scrollbar-sm::-webkit-scrollbar-thumb { - border: 3px solid rgba(0, 0, 0, 0); -} -/* scrollbar md size */ -.scrollbar-md::-webkit-scrollbar { - height: 14px; - width: 14px; -} -.scrollbar-md::-webkit-scrollbar-thumb { - border: 3px solid rgba(0, 0, 0, 0); -} -/* scrollbar lg size */ - -.scrollbar-lg::-webkit-scrollbar { - height: 16px; - width: 16px; -} -.scrollbar-lg::-webkit-scrollbar-thumb { - border: 4px solid rgba(0, 0, 0, 0); -} -/* end scrollbar style */ - /* progress bar */ .progress-bar { fill: currentColor; - color: rgba(var(--color-sidebar-background-100)); -} - -::-webkit-input-placeholder, -::placeholder, -:-ms-input-placeholder { - color: rgb(var(--color-text-400)); + color: var(--background-color-surface-1); } /* Progress Bar Styles */ :root { - --bprogress-color: rgb(var(--color-primary-100)) !important; + --bprogress-color: var(--background-color-accent-primary); --bprogress-height: 2.5px !important; } @@ -429,8 +27,8 @@ body { .bprogress .bar { background: linear-gradient( 90deg, - rgba(var(--color-primary-100), 0.8) 0%, - rgba(var(--color-primary-100), 1) 100% + --alpha(var(--background-color-accent-primary) / 80%) 0%, + --alpha(var(--background-color-accent-primary) / 100%) 100% ) !important; will-change: width, opacity; } @@ -438,7 +36,7 @@ body { .bprogress .peg { display: block; box-shadow: - 0 0 8px rgba(var(--color-primary-100), 0.6), - 0 0 4px rgba(var(--color-primary-100), 0.4) !important; + 0 0 8px --alpha(var(--background-color-accent-primary) / 60%), + 0 0 4px --alpha(var(--background-color-accent-primary) / 40%) !important; will-change: transform, opacity; } diff --git a/apps/admin/tailwind.config.cjs b/apps/admin/tailwind.config.cjs deleted file mode 100644 index 9bc917eb4..000000000 --- a/apps/admin/tailwind.config.cjs +++ /dev/null @@ -1,5 +0,0 @@ -const sharedConfig = require("@plane/tailwind-config/tailwind.config.js"); - -module.exports = { - presets: [sharedConfig], -}; diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json index 90dedb762..2d04cbe81 100644 --- a/apps/admin/tsconfig.json +++ b/apps/admin/tsconfig.json @@ -9,12 +9,7 @@ "types": ["vite/client"], "paths": { "package.json": ["./package.json"], - "ce/*": ["./ce/*"], - "@/app/*": ["./app/*"], - "@/*": ["./core/*"], - "@/plane-admin/*": ["./ce/*"], - "@/ce/*": ["./ce/*"], - "@/styles/*": ["./styles/*"] + "@/*": ["./*"] } }, "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"], diff --git a/apps/admin/utils/public-asset.ts b/apps/admin/utils/public-asset.ts new file mode 100644 index 000000000..382ca5fe3 --- /dev/null +++ b/apps/admin/utils/public-asset.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export {}; diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index c9d97157f..f61d9b49e 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -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"; diff --git a/apps/api/bin/docker-entrypoint-api.sh b/apps/api/bin/docker-entrypoint-api.sh index 5a1da1570..7a6402864 100755 --- a/apps/api/bin/docker-entrypoint-api.sh +++ b/apps/api/bin/docker-entrypoint-api.sh @@ -32,4 +32,7 @@ python manage.py create_bucket # Clear Cache before starting to remove stale values python manage.py clear_cache +# Collect static files +python manage.py collectstatic --noinput + exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile - diff --git a/apps/api/manage.py b/apps/api/manage.py index 972869462..a79268b37 100644 --- a/apps/api/manage.py +++ b/apps/api/manage.py @@ -1,4 +1,8 @@ #!/usr/bin/env python +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import os import sys diff --git a/apps/api/package.json b/apps/api/package.json index 9f4b7879c..99f5de987 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,7 +1,7 @@ { "name": "plane-api", - "version": "1.2.3", - "license": "AGPL-3.0", + "version": "1.3.0", "private": true, - "description": "API server powering Plane's backend" + "description": "API server powering Plane's backend", + "license": "AGPL-3.0" } diff --git a/apps/api/plane/__init__.py b/apps/api/plane/__init__.py index 53f4ccb1d..f561e3e63 100644 --- a/apps/api/plane/__init__.py +++ b/apps/api/plane/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .celery import app as celery_app __all__ = ("celery_app",) diff --git a/apps/api/plane/analytics/__init__.py b/apps/api/plane/analytics/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/analytics/__init__.py +++ b/apps/api/plane/analytics/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/analytics/apps.py b/apps/api/plane/analytics/apps.py index 52a59f313..b11b7bfc9 100644 --- a/apps/api/plane/analytics/apps.py +++ b/apps/api/plane/analytics/apps.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.apps import AppConfig diff --git a/apps/api/plane/api/__init__.py b/apps/api/plane/api/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/api/__init__.py +++ b/apps/api/plane/api/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/api/apps.py b/apps/api/plane/api/apps.py index f1f531118..90688c130 100644 --- a/apps/api/plane/api/apps.py +++ b/apps/api/plane/api/apps.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.apps import AppConfig diff --git a/apps/api/plane/api/middleware/__init__.py b/apps/api/plane/api/middleware/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/api/middleware/__init__.py +++ b/apps/api/plane/api/middleware/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/api/middleware/api_authentication.py b/apps/api/plane/api/middleware/api_authentication.py index ddabb4132..abd813985 100644 --- a/apps/api/plane/api/middleware/api_authentication.py +++ b/apps/api/plane/api/middleware/api_authentication.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.utils import timezone from django.db.models import Q diff --git a/apps/api/plane/api/rate_limit.py b/apps/api/plane/api/rate_limit.py index 0d266e98b..33df895cb 100644 --- a/apps/api/plane/api/rate_limit.py +++ b/apps/api/plane/api/rate_limit.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # python imports import os diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index 6525ddce6..2ab639d54 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .user import UserLiteSerializer from .workspace import WorkspaceLiteSerializer from .project import ( @@ -21,6 +25,10 @@ from .issue import ( IssueCommentCreateSerializer, IssueLinkCreateSerializer, IssueLinkUpdateSerializer, + IssueRelationCreateSerializer, + IssueRelationResponseSerializer, + IssueRelationSerializer, + RelatedIssueSerializer, ) from .state import StateLiteSerializer, StateSerializer from .cycle import ( @@ -45,7 +53,7 @@ from .intake import ( IntakeIssueCreateSerializer, IntakeIssueUpdateSerializer, ) -from .estimate import EstimatePointSerializer +from .estimate import EstimateSerializer, EstimatePointSerializer from .asset import ( UserAssetUploadSerializer, AssetUpdateSerializer, @@ -55,4 +63,4 @@ from .asset import ( ) from .invite import WorkspaceInviteSerializer from .member import ProjectMemberSerializer -from .sticky import StickySerializer \ No newline at end of file +from .sticky import StickySerializer diff --git a/apps/api/plane/api/serializers/asset.py b/apps/api/plane/api/serializers/asset.py index 6b74b3757..363b5eb84 100644 --- a/apps/api/plane/api/serializers/asset.py +++ b/apps/api/plane/api/serializers/asset.py @@ -1,3 +1,7 @@ +# 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 import serializers diff --git a/apps/api/plane/api/serializers/base.py b/apps/api/plane/api/serializers/base.py index bc790f2cd..2e39a01fd 100644 --- a/apps/api/plane/api/serializers/base.py +++ b/apps/api/plane/api/serializers/base.py @@ -1,3 +1,7 @@ +# 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 import serializers diff --git a/apps/api/plane/api/serializers/cycle.py b/apps/api/plane/api/serializers/cycle.py index f2724231a..9b6aed516 100644 --- a/apps/api/plane/api/serializers/cycle.py +++ b/apps/api/plane/api/serializers/cycle.py @@ -1,3 +1,7 @@ +# 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 import pytz from rest_framework import serializers diff --git a/apps/api/plane/api/serializers/estimate.py b/apps/api/plane/api/serializers/estimate.py index b670006d5..978003240 100644 --- a/apps/api/plane/api/serializers/estimate.py +++ b/apps/api/plane/api/serializers/estimate.py @@ -1,17 +1,37 @@ +# 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 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"] diff --git a/apps/api/plane/api/serializers/intake.py b/apps/api/plane/api/serializers/intake.py index 40cbba38b..12bbd4572 100644 --- a/apps/api/plane/api/serializers/intake.py +++ b/apps/api/plane/api/serializers/intake.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from .base import BaseSerializer from .issue import IssueExpandSerializer @@ -13,11 +17,14 @@ class IssueForIntakeSerializer(BaseSerializer): content validation and priority assignment for triage workflows. """ + description = serializers.JSONField(source="description_json", required=False, allow_null=True) + class Meta: model = Issue fields = [ "name", - "description", + "description", # Deprecated + "description_json", "description_html", "priority", ] diff --git a/apps/api/plane/api/serializers/invite.py b/apps/api/plane/api/serializers/invite.py index 5b52dc03c..18c1c0206 100644 --- a/apps/api/plane/api/serializers/invite.py +++ b/apps/api/plane/api/serializers/invite.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.core.exceptions import ValidationError from django.core.validators import validate_email diff --git a/apps/api/plane/api/serializers/issue.py b/apps/api/plane/api/serializers/issue.py index d86dfa6b6..6468ddbc8 100644 --- a/apps/api/plane/api/serializers/issue.py +++ b/apps/api/plane/api/serializers/issue.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.utils import timezone from lxml import html @@ -16,6 +20,7 @@ from plane.db.models import ( IssueComment, IssueLabel, IssueLink, + IssueRelation, Label, ProjectMember, State, @@ -65,7 +70,7 @@ class IssueSerializer(BaseSerializer): class Meta: model = Issue read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at"] - exclude = ["description", "description_stripped"] + exclude = ["description_json", "description_stripped"] def validate(self, data): if ( @@ -475,6 +480,184 @@ class IssueLinkSerializer(BaseSerializer): ] +class IssueRelationResponseSerializer(serializers.Serializer): + """ + Serializer for issue relations response showing grouped relation types. + + Returns issue IDs organized by relation type for efficient client-side processing. + """ + + blocking = serializers.ListField( + child=serializers.UUIDField(), + help_text="List of issue IDs that are blocking this issue", + ) + blocked_by = serializers.ListField( + child=serializers.UUIDField(), + help_text="List of issue IDs that this issue is blocked by", + ) + duplicate = serializers.ListField( + child=serializers.UUIDField(), + help_text="List of issue IDs that are duplicates of this issue", + ) + relates_to = serializers.ListField( + child=serializers.UUIDField(), + help_text="List of issue IDs that relate to this issue", + ) + start_after = serializers.ListField( + child=serializers.UUIDField(), + help_text="List of issue IDs that start after this issue", + ) + start_before = serializers.ListField( + child=serializers.UUIDField(), + help_text="List of issue IDs that start before this issue", + ) + finish_after = serializers.ListField( + child=serializers.UUIDField(), + help_text="List of issue IDs that finish after this issue", + ) + finish_before = serializers.ListField( + child=serializers.UUIDField(), + help_text="List of issue IDs 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. @@ -633,6 +816,7 @@ class IssueExpandSerializer(BaseSerializer): labels = serializers.SerializerMethodField() assignees = serializers.SerializerMethodField() state = StateLiteSerializer(read_only=True) + description = serializers.JSONField(source="description_json", read_only=True) def get_labels(self, obj): expand = self.context.get("expand", []) diff --git a/apps/api/plane/api/serializers/member.py b/apps/api/plane/api/serializers/member.py index 3aa9644b4..266a4cfe1 100644 --- a/apps/api/plane/api/serializers/member.py +++ b/apps/api/plane/api/serializers/member.py @@ -1,3 +1,7 @@ +# 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 import serializers diff --git a/apps/api/plane/api/serializers/module.py b/apps/api/plane/api/serializers/module.py index d1e3b0d81..8ab73a33e 100644 --- a/apps/api/plane/api/serializers/module.py +++ b/apps/api/plane/api/serializers/module.py @@ -1,3 +1,7 @@ +# 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 import serializers diff --git a/apps/api/plane/api/serializers/project.py b/apps/api/plane/api/serializers/project.py index 770957e08..644b5ba10 100644 --- a/apps/api/plane/api/serializers/project.py +++ b/apps/api/plane/api/serializers/project.py @@ -1,15 +1,17 @@ +# 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 import random from rest_framework import serializers + +# Python imports +import re + # Module imports -from plane.db.models import ( - Project, - ProjectIdentifier, - WorkspaceMember, - State, - Estimate, -) +from plane.db.models import Project, ProjectIdentifier, WorkspaceMember, State, Estimate from plane.utils.content_validator import ( validate_html_content, @@ -17,7 +19,7 @@ from plane.utils.content_validator import ( from .base import BaseSerializer -class ProjectCreateSerializer(BaseSerializer): +class ProjectCreateSerializer(BaseSerializer): """ Serializer for creating projects with workspace validation. @@ -103,6 +105,15 @@ class ProjectCreateSerializer(BaseSerializer): ] def validate(self, data): + project_name = data.get("name", None) + project_identifier = data.get("identifier", None) + + if project_name is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_name): + raise serializers.ValidationError("Project name cannot contain special characters.") + + 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( @@ -123,6 +134,7 @@ class ProjectCreateSerializer(BaseSerializer): def create(self, validated_data): identifier = validated_data.get("identifier", "").strip().upper() + if identifier == "": raise serializers.ValidationError(detail="Project Identifier is required") @@ -161,6 +173,15 @@ class ProjectUpdateSerializer(ProjectCreateSerializer): read_only_fields = ProjectCreateSerializer.Meta.read_only_fields def update(self, instance, validated_data): + project_name = validated_data.get("name", None) + project_identifier = validated_data.get("identifier", None) + + if project_name is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_name): + raise serializers.ValidationError("Project name cannot contain special characters.") + + 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.") + """Update a project""" if ( validated_data.get("default_state", None) is not None @@ -211,6 +232,15 @@ class ProjectSerializer(BaseSerializer): ] def validate(self, data): + project_name = data.get("name", None) + project_identifier = data.get("identifier", None) + + if project_name is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_name): + raise serializers.ValidationError("Project name cannot contain special characters.") + + 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.") + # Check project lead should be a member of the workspace if ( data.get("project_lead", None) is not None diff --git a/apps/api/plane/api/serializers/state.py b/apps/api/plane/api/serializers/state.py index c279529b8..c07fe78bb 100644 --- a/apps/api/plane/api/serializers/state.py +++ b/apps/api/plane/api/serializers/state.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from .base import BaseSerializer from plane.db.models import State, StateGroup diff --git a/apps/api/plane/api/serializers/sticky.py b/apps/api/plane/api/serializers/sticky.py index 067fc1b89..e2ffee8a9 100644 --- a/apps/api/plane/api/serializers/sticky.py +++ b/apps/api/plane/api/serializers/sticky.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from rest_framework import serializers from .base import BaseSerializer diff --git a/apps/api/plane/api/serializers/user.py b/apps/api/plane/api/serializers/user.py index 805eb9fe1..24bb6b902 100644 --- a/apps/api/plane/api/serializers/user.py +++ b/apps/api/plane/api/serializers/user.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from rest_framework import serializers # Module imports diff --git a/apps/api/plane/api/serializers/workspace.py b/apps/api/plane/api/serializers/workspace.py index e98683c2f..6b85fcabc 100644 --- a/apps/api/plane/api/serializers/workspace.py +++ b/apps/api/plane/api/serializers/workspace.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from plane.db.models import Workspace from .base import BaseSerializer diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py index 593501939..4a202431b 100644 --- a/apps/api/plane/api/urls/__init__.py +++ b/apps/api/plane/api/urls/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .asset import urlpatterns as asset_patterns from .cycle import urlpatterns as cycle_patterns from .intake import urlpatterns as intake_patterns diff --git a/apps/api/plane/api/urls/asset.py b/apps/api/plane/api/urls/asset.py index 5bdd4d914..abd160242 100644 --- a/apps/api/plane/api/urls/asset.py +++ b/apps/api/plane/api/urls/asset.py @@ -1,3 +1,7 @@ +# 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 import ( diff --git a/apps/api/plane/api/urls/cycle.py b/apps/api/plane/api/urls/cycle.py index a2cab1fe6..6d582784e 100644 --- a/apps/api/plane/api/urls/cycle.py +++ b/apps/api/plane/api/urls/cycle.py @@ -1,3 +1,7 @@ +# 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.cycle import ( diff --git a/apps/api/plane/api/urls/estimate.py b/apps/api/plane/api/urls/estimate.py new file mode 100644 index 000000000..3fe5d3b7b --- /dev/null +++ b/apps/api/plane/api/urls/estimate.py @@ -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//projects//estimates/", + ProjectEstimateAPIEndpoint.as_view(http_method_names=["get", "post", "patch", "delete"]), + name="project-estimate", + ), + path( + "workspaces//projects//estimates//estimate-points/", + EstimatePointListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="estimate-point-list-create", + ), + path( + "workspaces//projects//estimates//estimate-points//", + EstimatePointDetailAPIEndpoint.as_view(http_method_names=["patch", "delete"]), + name="estimate-point-detail", + ), +] diff --git a/apps/api/plane/api/urls/intake.py b/apps/api/plane/api/urls/intake.py index 5538467aa..6a5480459 100644 --- a/apps/api/plane/api/urls/intake.py +++ b/apps/api/plane/api/urls/intake.py @@ -1,3 +1,7 @@ +# 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 import ( diff --git a/apps/api/plane/api/urls/invite.py b/apps/api/plane/api/urls/invite.py index 9d73cb6ef..7c1ce5e6a 100644 --- a/apps/api/plane/api/urls/invite.py +++ b/apps/api/plane/api/urls/invite.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.urls import path, include @@ -15,4 +19,4 @@ router.register(r"invitations", WorkspaceInvitationsViewset, basename="workspace # Wrap the router URLs with the workspace slug path urlpatterns = [ path("workspaces//", include(router.urls)), -] \ No newline at end of file +] diff --git a/apps/api/plane/api/urls/label.py b/apps/api/plane/api/urls/label.py index f7ee57b17..358806fb7 100644 --- a/apps/api/plane/api/urls/label.py +++ b/apps/api/plane/api/urls/label.py @@ -1,3 +1,7 @@ +# 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 import LabelListCreateAPIEndpoint, LabelDetailAPIEndpoint diff --git a/apps/api/plane/api/urls/member.py b/apps/api/plane/api/urls/member.py index 83c9dfbe5..4f2b03230 100644 --- a/apps/api/plane/api/urls/member.py +++ b/apps/api/plane/api/urls/member.py @@ -1,3 +1,7 @@ +# 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 import ( diff --git a/apps/api/plane/api/urls/module.py b/apps/api/plane/api/urls/module.py index 578f5c860..a0924100d 100644 --- a/apps/api/plane/api/urls/module.py +++ b/apps/api/plane/api/urls/module.py @@ -1,3 +1,7 @@ +# 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 import ( diff --git a/apps/api/plane/api/urls/project.py b/apps/api/plane/api/urls/project.py index 9cf9291aa..eb22249e0 100644 --- a/apps/api/plane/api/urls/project.py +++ b/apps/api/plane/api/urls/project.py @@ -1,9 +1,14 @@ +# 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 import ( ProjectListCreateAPIEndpoint, ProjectDetailAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint, + ProjectSummaryAPIEndpoint, ) urlpatterns = [ @@ -22,4 +27,9 @@ urlpatterns = [ ProjectArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]), name="project-archive-unarchive", ), + path( + "workspaces//projects//summary/", + ProjectSummaryAPIEndpoint.as_view(http_method_names=["get"]), + name="project-summary", + ), ] diff --git a/apps/api/plane/api/urls/schema.py b/apps/api/plane/api/urls/schema.py index 781dbe9de..9511ca02b 100644 --- a/apps/api/plane/api/urls/schema.py +++ b/apps/api/plane/api/urls/schema.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from drf_spectacular.views import ( SpectacularAPIView, SpectacularRedocView, diff --git a/apps/api/plane/api/urls/state.py b/apps/api/plane/api/urls/state.py index e35012a20..8b84abfbe 100644 --- a/apps/api/plane/api/urls/state.py +++ b/apps/api/plane/api/urls/state.py @@ -1,3 +1,7 @@ +# 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 import ( diff --git a/apps/api/plane/api/urls/sticky.py b/apps/api/plane/api/urls/sticky.py index 0066e77ea..0df9c49c3 100644 --- a/apps/api/plane/api/urls/sticky.py +++ b/apps/api/plane/api/urls/sticky.py @@ -1,3 +1,7 @@ +# 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, include from rest_framework.routers import DefaultRouter diff --git a/apps/api/plane/api/urls/user.py b/apps/api/plane/api/urls/user.py index 461b08333..33769b7c0 100644 --- a/apps/api/plane/api/urls/user.py +++ b/apps/api/plane/api/urls/user.py @@ -1,3 +1,7 @@ +# 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 import UserEndpoint diff --git a/apps/api/plane/api/urls/work_item.py b/apps/api/plane/api/urls/work_item.py index 7207df957..1a1704f27 100644 --- a/apps/api/plane/api/urls/work_item.py +++ b/apps/api/plane/api/urls/work_item.py @@ -1,3 +1,7 @@ +# 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 import ( @@ -13,6 +17,7 @@ from plane.api.views import ( IssueAttachmentDetailAPIEndpoint, WorkspaceIssueAPIEndpoint, IssueSearchEndpoint, + IssueRelationListCreateAPIEndpoint, ) # Deprecated url patterns @@ -141,6 +146,11 @@ new_url_patterns = [ IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), name="work-item-attachment-detail", ), + path( + "workspaces//projects//work-items//relations/", + IssueRelationListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="work-item-relation-list", + ), ] urlpatterns = old_url_patterns + new_url_patterns diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index 75b1b17c4..e8549afb4 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -1,7 +1,12 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .project import ( ProjectListCreateAPIEndpoint, ProjectDetailAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint, + ProjectSummaryAPIEndpoint, ) from .state import ( @@ -24,6 +29,7 @@ from .issue import ( IssueAttachmentListCreateAPIEndpoint, IssueAttachmentDetailAPIEndpoint, IssueSearchEndpoint, + IssueRelationListCreateAPIEndpoint, ) from .cycle import ( diff --git a/apps/api/plane/api/views/asset.py b/apps/api/plane/api/views/asset.py index a91ebc883..88c34c37c 100644 --- a/apps/api/plane/api/views/asset.py +++ b/apps/api/plane/api/views/asset.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python Imports import uuid diff --git a/apps/api/plane/api/views/base.py b/apps/api/plane/api/views/base.py index 2e6584430..fc65e7abd 100644 --- a/apps/api/plane/api/views/base.py +++ b/apps/api/plane/api/views/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import zoneinfo import logging diff --git a/apps/api/plane/api/views/cycle.py b/apps/api/plane/api/views/cycle.py index c92b27f59..30b04ed46 100644 --- a/apps/api/plane/api/views/cycle.py +++ b/apps/api/plane/api/views/cycle.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json @@ -195,9 +199,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView): # Current Cycle if cycle_view == "current": - queryset = queryset.filter( - start_date__lte=timezone.now(), end_date__gte=timezone.now() - ) + queryset = queryset.filter(start_date__lte=timezone.now(), end_date__gte=timezone.now()) data = CycleSerializer( queryset, many=True, @@ -254,9 +256,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView): # Incomplete Cycles if cycle_view == "incomplete": - queryset = queryset.filter( - Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True) - ) + queryset = queryset.filter(Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True)) return self.paginate( request=request, queryset=(queryset), @@ -302,17 +302,10 @@ class CycleListCreateAPIEndpoint(BaseAPIView): Create a new development cycle with specified name, description, and date range. Supports external ID tracking for integration purposes. """ - if ( - request.data.get("start_date", None) is None - and request.data.get("end_date", None) is None - ) or ( - request.data.get("start_date", None) is not None - and request.data.get("end_date", None) is not None + if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or ( + request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None ): - - serializer = CycleCreateSerializer( - data=request.data, context={"request": request} - ) + serializer = CycleCreateSerializer(data=request.data, context={"request": request}) if serializer.is_valid(): if ( request.data.get("external_id") @@ -355,9 +348,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) else: return Response( - { - "error": "Both start date and end date are either required or are to be null" - }, + {"error": "Both start date and end date are either required or are to be null"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -505,9 +496,7 @@ class CycleDetailAPIEndpoint(BaseAPIView): """ cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - current_instance = json.dumps( - CycleSerializer(cycle).data, cls=DjangoJSONEncoder - ) + current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder) if cycle.archived_at: return Response( @@ -520,20 +509,14 @@ class CycleDetailAPIEndpoint(BaseAPIView): if cycle.end_date is not None and cycle.end_date < timezone.now(): if "sort_order" in request_data: # Can only change sort order - request_data = { - "sort_order": request_data.get("sort_order", cycle.sort_order) - } + request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)} else: return Response( - { - "error": "The Cycle has already been completed so it cannot be edited" - }, + {"error": "The Cycle has already been completed so it cannot be edited"}, status=status.HTTP_400_BAD_REQUEST, ) - serializer = CycleUpdateSerializer( - cycle, data=request.data, partial=True, context={"request": request} - ) + serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True, context={"request": request}) if serializer.is_valid(): if ( request.data.get("external_id") @@ -541,9 +524,7 @@ class CycleDetailAPIEndpoint(BaseAPIView): and Cycle.objects.filter( project_id=project_id, workspace__slug=slug, - external_source=request.data.get( - "external_source", cycle.external_source - ), + external_source=request.data.get("external_source", cycle.external_source), external_id=request.data.get("external_id"), ).exists() ): @@ -600,11 +581,7 @@ class CycleDetailAPIEndpoint(BaseAPIView): status=status.HTTP_403_FORBIDDEN, ) - cycle_issues = list( - CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( - "issue", flat=True - ) - ) + cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True)) issue_activity.delay( type="cycle.activity.deleted", @@ -624,9 +601,7 @@ class CycleDetailAPIEndpoint(BaseAPIView): # Delete the cycle cycle.delete() # Delete the user favorite cycle - UserFavorite.objects.filter( - entity_type="cycle", entity_identifier=pk, project_id=project_id - ).delete() + UserFavorite.objects.filter(entity_type="cycle", entity_identifier=pk, project_id=project_id).delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -764,9 +739,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): return self.paginate( request=request, queryset=(self.get_queryset()), - on_results=lambda cycles: CycleSerializer( - cycles, many=True, fields=self.fields, expand=self.expand - ).data, + on_results=lambda cycles: CycleSerializer(cycles, many=True, fields=self.fields, expand=self.expand).data, ) @cycle_docs( @@ -785,9 +758,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): Move a completed cycle to archived status for historical tracking. Only cycles that have ended can be archived. """ - cycle = Cycle.objects.get( - pk=cycle_id, project_id=project_id, workspace__slug=slug - ) + cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug) if cycle.end_date >= timezone.now(): return Response( {"error": "Only completed cycles can be archived"}, @@ -818,9 +789,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): Restore an archived cycle to active status, making it available for regular use. The cycle will reappear in active cycle lists. """ - cycle = Cycle.objects.get( - pk=cycle_id, project_id=project_id, workspace__slug=slug - ) + cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug) cycle.archived_at = None cycle.save() return Response(status=status.HTTP_204_NO_CONTENT) @@ -883,9 +852,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView): # List order_by = request.GET.get("order_by", "created_at") issues = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True - ) + Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True) .annotate( sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() @@ -922,9 +889,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView): return self.paginate( request=request, queryset=(issues), - on_results=lambda issues: IssueSerializer( - issues, many=True, fields=self.fields, expand=self.expand - ).data, + on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data, ) @cycle_docs( @@ -958,9 +923,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - cycle = Cycle.objects.get( - workspace__slug=slug, project_id=project_id, pk=cycle_id - ) + cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=cycle_id) if cycle.end_date is not None and cycle.end_date < timezone.now(): return Response( @@ -972,13 +935,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView): ) # Get all CycleWorkItems already created - cycle_issues = list( - CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues) - ) + cycle_issues = list(CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues)) existing_issues = [ - str(cycle_issue.issue_id) - for cycle_issue in cycle_issues - if str(cycle_issue.issue_id) in issues + str(cycle_issue.issue_id) for cycle_issue in cycle_issues if str(cycle_issue.issue_id) in issues ] new_issues = list(set(issues) - set(existing_issues)) @@ -1029,9 +988,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView): current_instance=json.dumps( { "updated_cycle_issues": update_cycle_issue_activity, - "created_cycle_issues": serializers.serialize( - "json", created_records - ), + "created_cycle_issues": serializers.serialize("json", created_records), } ), epoch=int(timezone.now().timestamp()), @@ -1107,9 +1064,7 @@ class CycleIssueDetailAPIEndpoint(BaseAPIView): cycle_id=cycle_id, issue_id=issue_id, ) - serializer = CycleIssueSerializer( - cycle_issue, fields=self.fields, expand=self.expand - ) + serializer = CycleIssueSerializer(cycle_issue, fields=self.fields, expand=self.expand) return Response(serializer.data, status=status.HTTP_200_OK) @cycle_docs( @@ -1214,7 +1169,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): {"error": "New Cycle Id is required"}, status=status.HTTP_400_BAD_REQUEST, ) - + old_cycle = Cycle.objects.get( workspace__slug=slug, project_id=project_id, diff --git a/apps/api/plane/api/views/estimate.py b/apps/api/plane/api/views/estimate.py new file mode 100644 index 000000000..915cc8e5c --- /dev/null +++ b/apps/api/plane/api/views/estimate.py @@ -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) diff --git a/apps/api/plane/api/views/intake.py b/apps/api/plane/api/views/intake.py index 216b27afc..2df2d3069 100644 --- a/apps/api/plane/api/views/intake.py +++ b/apps/api/plane/api/views/intake.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json @@ -180,11 +184,14 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView): ) # create an issue + issue_data = request.data.get("issue", {}) + # Accept both "description" and "description_json" keys for the description_json field + description_json = issue_data.get("description") or issue_data.get("description_json") or {} issue = Issue.objects.create( - name=request.data.get("issue", {}).get("name"), - description=request.data.get("issue", {}).get("description", {}), - description_html=request.data.get("issue", {}).get("description_html", "

    "), - priority=request.data.get("issue", {}).get("priority", "none"), + name=issue_data.get("name"), + description_json=description_json, + description_html=issue_data.get("description_html", "

    "), + priority=issue_data.get("priority", "none"), project_id=project_id, state_id=triage_state.id, ) @@ -365,10 +372,11 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView): # Only allow guests to edit name and description if project_member.role <= 5: + description_json = issue_data.get("description") or issue_data.get("description_json") or {} issue_data = { "name": issue_data.get("name", issue.name), "description_html": issue_data.get("description_html", issue.description_html), - "description": issue_data.get("description", issue.description), + "description_json": description_json, } issue_serializer = IssueSerializer(issue, data=issue_data, partial=True) diff --git a/apps/api/plane/api/views/invite.py b/apps/api/plane/api/views/invite.py index f1263b009..f1dd6af25 100644 --- a/apps/api/plane/api/views/invite.py +++ b/apps/api/plane/api/views/invite.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index fe32fe3fd..97e8e7cee 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json import uuid @@ -19,7 +23,11 @@ from django.db.models import ( Value, When, Subquery, + UUIDField, ) +from django.db.models.functions import Coalesce +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField from django.utils import timezone from django.conf import settings @@ -41,6 +49,9 @@ from plane.api.serializers import ( IssueActivitySerializer, IssueCommentSerializer, IssueLinkSerializer, + IssueRelationCreateSerializer, + IssueRelationResponseSerializer, + IssueRelationSerializer, IssueSerializer, LabelSerializer, IssueAttachmentUploadSerializer, @@ -49,6 +60,7 @@ from plane.api.serializers import ( IssueLinkCreateSerializer, IssueLinkUpdateSerializer, LabelCreateUpdateSerializer, + RelatedIssueSerializer, ) from plane.app.permissions import ( ProjectEntityPermission, @@ -62,6 +74,7 @@ from plane.db.models import ( FileAsset, IssueComment, IssueLink, + IssueRelation, Label, Project, ProjectMember, @@ -72,10 +85,12 @@ from plane.settings.storage import S3Storage 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, @@ -625,6 +640,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 @@ -673,6 +698,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: @@ -748,6 +783,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) @@ -1085,9 +1130,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( @@ -1192,9 +1237,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) @@ -1343,9 +1388,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( @@ -1654,9 +1699,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 + ), ) @@ -2216,3 +2261,224 @@ 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": [ + "550e8400-e29b-41d4-a716-446655440000", + "550e8400-e29b-41d4-a716-446655440001", + ], + "blocked_by": ["550e8400-e29b-41d4-a716-446655440002"], + "duplicate": [], + "relates_to": ["550e8400-e29b-41d4-a716-446655440003"], + "start_after": [], + "start_before": ["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. + """ + empty_uuid_array = Value([], output_field=ArrayField(UUIDField())) + + def _agg_ids(field, **filter_kwargs): + return Coalesce( + ArrayAgg(field, filter=Q(**filter_kwargs), distinct=True), + empty_uuid_array, + ) + + issue_relation_qs = IssueRelation.objects.filter( + Q(issue_id=issue_id) | Q(related_issue_id=issue_id), + workspace__slug=slug, + ) + + relation_ids = issue_relation_qs.aggregate( + blocking_ids=_agg_ids("issue_id", relation_type="blocked_by", related_issue_id=issue_id), + blocked_by_ids=_agg_ids("related_issue_id", relation_type="blocked_by", issue_id=issue_id), + duplicate_ids=_agg_ids("related_issue_id", relation_type="duplicate", issue_id=issue_id), + duplicate_ids_related=_agg_ids("issue_id", relation_type="duplicate", related_issue_id=issue_id), + relates_to_ids=_agg_ids("related_issue_id", relation_type="relates_to", issue_id=issue_id), + relates_to_ids_related=_agg_ids("issue_id", relation_type="relates_to", related_issue_id=issue_id), + start_after_ids=_agg_ids("issue_id", relation_type="start_before", related_issue_id=issue_id), + start_before_ids=_agg_ids("related_issue_id", relation_type="start_before", issue_id=issue_id), + finish_after_ids=_agg_ids("issue_id", relation_type="finish_before", related_issue_id=issue_id), + finish_before_ids=_agg_ids("related_issue_id", relation_type="finish_before", issue_id=issue_id), + ) + + response_data = { + "blocking": relation_ids["blocking_ids"], + "blocked_by": relation_ids["blocked_by_ids"], + "duplicate": list(set(relation_ids["duplicate_ids"] + relation_ids["duplicate_ids_related"])), + "relates_to": list(set(relation_ids["relates_to_ids"] + relation_ids["relates_to_ids_related"])), + "start_after": relation_ids["start_after_ids"], + "start_before": relation_ids["start_before_ids"], + "finish_after": relation_ids["finish_after_ids"], + "finish_before": relation_ids["finish_before_ids"], + } + + 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, + ) diff --git a/apps/api/plane/api/views/member.py b/apps/api/plane/api/views/member.py index 854bc7ae6..adb28be00 100644 --- a/apps/api/plane/api/views/member.py +++ b/apps/api/plane/api/views/member.py @@ -1,3 +1,7 @@ +# 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 @@ -154,7 +158,6 @@ class ProjectMemberListCreateAPIEndpoint(BaseAPIView): # API endpoint to get and update a project member class ProjectMemberDetailAPIEndpoint(ProjectMemberListCreateAPIEndpoint): - @extend_schema( operation_id="get_project_member", summary="Get project member", diff --git a/apps/api/plane/api/views/module.py b/apps/api/plane/api/views/module.py index a4e0f3fe8..61e198b48 100644 --- a/apps/api/plane/api/views/module.py +++ b/apps/api/plane/api/views/module.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json @@ -410,7 +414,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView): {"error": "Archived module cannot be edited"}, status=status.HTTP_400_BAD_REQUEST, ) - serializer = ModuleSerializer(module, data=request.data, context={"project_id": project_id}, partial=True) + serializer = ModuleUpdateSerializer(module, data=request.data, context={"project_id": project_id}, partial=True) if serializer.is_valid(): if ( request.data.get("external_id") diff --git a/apps/api/plane/api/views/project.py b/apps/api/plane/api/views/project.py index fa735f557..5ab0fd1c1 100644 --- a/apps/api/plane/api/views/project.py +++ b/apps/api/plane/api/views/project.py @@ -1,9 +1,14 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json # Django imports from django.db import IntegrityError -from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery +from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery, Count +from django.db.models.functions import Coalesce from django.utils import timezone from django.core.serializers.json import DjangoJSONEncoder @@ -18,7 +23,6 @@ from drf_spectacular.utils import OpenApiResponse, OpenApiRequest from plane.db.models import ( Cycle, Intake, - IssueUserProperty, Module, Project, DeployBoard, @@ -27,6 +31,11 @@ from plane.db.models import ( DEFAULT_STATES, Workspace, UserFavorite, + Label, + Issue, + StateGroup, + IntakeIssue, + ProjectPage, ) from plane.bgtasks.webhook_task import model_activity, webhook_activity from .base import BaseAPIView @@ -36,7 +45,7 @@ from plane.api.serializers import ( ProjectCreateSerializer, ProjectUpdateSerializer, ) -from plane.app.permissions import ProjectBasePermission +from plane.app.permissions import ProjectBasePermission, WorkSpaceAdminPermission from plane.utils.openapi import ( project_docs, PROJECT_ID_PARAMETER, @@ -179,9 +188,9 @@ class ProjectListCreateAPIEndpoint(BaseAPIView): return self.paginate( request=request, queryset=(projects), - on_results=lambda projects: ProjectSerializer( - projects, many=True, fields=self.fields, expand=self.expand - ).data, + on_results=lambda projects: ( + ProjectSerializer(projects, many=True, fields=self.fields, expand=self.expand).data + ), ) @project_docs( @@ -210,14 +219,14 @@ class ProjectListCreateAPIEndpoint(BaseAPIView): """ try: workspace = Workspace.objects.get(slug=slug) + serializer = ProjectCreateSerializer(data={**request.data}, context={"workspace_id": workspace.id}) + if serializer.is_valid(): serializer.save() # Add the user as Administrator to the project _ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, role=20) - # Also create the issue property for the user - _ = IssueUserProperty.objects.create(project_id=serializer.instance.id, user=request.user) if serializer.instance.project_lead is not None and str(serializer.instance.project_lead) != str( request.user.id @@ -227,11 +236,6 @@ class ProjectListCreateAPIEndpoint(BaseAPIView): member_id=serializer.instance.project_lead, role=20, ) - # Also create the issue property for the user - IssueUserProperty.objects.create( - project_id=serializer.instance.id, - user_id=serializer.instance.project_lead, - ) State.objects.bulk_create( [ @@ -550,3 +554,119 @@ class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView): project.archived_at = None project.save() return Response(status=status.HTTP_204_NO_CONTENT) + + +ALLOWED_PROJECT_SUMMARY_FIELDS = [ + "members", + "states", + "labels", + "cycles", + "modules", + "issues", + "intakes", + "pages", +] + + +class ProjectSummaryAPIEndpoint(BaseAPIView): + permission_classes = [WorkSpaceAdminPermission] + use_read_replica = True + + def get(self, request, slug, project_id): + """Get project summary + + Get the summary of a project + """ + project = Project.objects.filter(pk=project_id, workspace__slug=slug).first() + if not project: + return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND) + fields = request.GET.get("fields", "").split(",") + requested_fields = set(filter(None, (f.strip() for f in fields))) & set(ALLOWED_PROJECT_SUMMARY_FIELDS) + if not requested_fields: + requested_fields = set(ALLOWED_PROJECT_SUMMARY_FIELDS) + + # Single DB round-trip with only requested count subqueries + counts = self._get_all_summary_counts(project_id, requested_fields) + counts_dict = {field: counts[field] for field in requested_fields} + summary = { + "id": project.id, + "name": project.name, + "identifier": project.identifier, + "counts": counts_dict, + } + return Response(summary, status=status.HTTP_200_OK) + + # Getting all summary counts in one ORM query; only runs subqueries for requested fields. + def _get_all_summary_counts(self, project_id, requested_fields): + """Return requested summary counts in one ORM query; only runs subqueries for requested fields.""" + + # Using a different annotation name for 'pages' to avoid conflict with Project.pages (M2M from Page) + def _annotation_name(field): + return "pages_count" if field == "pages" else field + + subquery_builders = { + "members": lambda: ( + ProjectMember.objects.filter(project_id=OuterRef("pk"), is_active=True) + .values("project_id") + .annotate(count=Count("*")) + .values("count") + ), + "states": lambda: ( + State.objects.filter(project_id=OuterRef("pk")) + .values("project_id") + .annotate(count=Count("*")) + .values("count") + ), + "labels": lambda: ( + Label.objects.filter(project_id=OuterRef("pk")) + .values("project_id") + .annotate(count=Count("*")) + .values("count") + ), + "cycles": lambda: ( + Cycle.objects.filter(project_id=OuterRef("pk")) + .values("project_id") + .annotate(count=Count("*")) + .values("count") + ), + "modules": lambda: ( + Module.objects.filter(project_id=OuterRef("pk")) + .values("project_id") + .annotate(count=Count("*")) + .values("count") + ), + "issues": lambda: ( + Issue.objects.filter(project_id=OuterRef("pk")) + .exclude(state__group=StateGroup.TRIAGE.value) + .values("project_id") + .annotate(count=Count("*")) + .values("count") + ), + "intakes": lambda: ( + IntakeIssue.objects.filter(project_id=OuterRef("pk")) + .values("project_id") + .annotate(count=Count("*")) + .values("count") + ), + "pages": lambda: ( + ProjectPage.objects.filter(project_id=OuterRef("pk")) + .values("project_id") + .annotate(count=Count("*")) + .values("count") + ), + } + + # Build annotations dictionary for the requested fields + annotations = { + _annotation_name(field): Coalesce(Subquery(subquery_builders[field]()), 0) for field in requested_fields + } + + # Prepare values list for the annotation names + fields_list = sorted(requested_fields) + values_list = [_annotation_name(f) for f in fields_list] + # Execute the query and get the result + query_result = Project.objects.filter(pk=project_id).annotate(**annotations).values(*values_list).first() + if not query_result: + return {field: 0 for field in requested_fields} + # Return the result as a dictionary + return {field: query_result[_annotation_name(field)] for field in requested_fields} diff --git a/apps/api/plane/api/views/state.py b/apps/api/plane/api/views/state.py index 8d2633e67..eac0ee258 100644 --- a/apps/api/plane/api/views/state.py +++ b/apps/api/plane/api/views/state.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.db import IntegrityError diff --git a/apps/api/plane/api/views/sticky.py b/apps/api/plane/api/views/sticky.py index a5173edc7..f6b4298f6 100644 --- a/apps/api/plane/api/views/sticky.py +++ b/apps/api/plane/api/views/sticky.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from rest_framework.response import Response from rest_framework import status diff --git a/apps/api/plane/api/views/user.py b/apps/api/plane/api/views/user.py index b874cec18..02d29d118 100644 --- a/apps/api/plane/api/views/user.py +++ b/apps/api/plane/api/views/user.py @@ -1,3 +1,7 @@ +# 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 import status from rest_framework.response import Response diff --git a/apps/api/plane/app/__init__.py b/apps/api/plane/app/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/app/__init__.py +++ b/apps/api/plane/app/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/app/apps.py b/apps/api/plane/app/apps.py index e3277fc4d..1dcf0d849 100644 --- a/apps/api/plane/app/apps.py +++ b/apps/api/plane/app/apps.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.apps import AppConfig diff --git a/apps/api/plane/app/middleware/__init__.py b/apps/api/plane/app/middleware/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/app/middleware/__init__.py +++ b/apps/api/plane/app/middleware/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/app/middleware/api_authentication.py b/apps/api/plane/app/middleware/api_authentication.py index ddabb4132..abd813985 100644 --- a/apps/api/plane/app/middleware/api_authentication.py +++ b/apps/api/plane/app/middleware/api_authentication.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.utils import timezone from django.db.models import Q diff --git a/apps/api/plane/app/permissions/__init__.py b/apps/api/plane/app/permissions/__init__.py index 849f7ba3e..22d27694e 100644 --- a/apps/api/plane/app/permissions/__init__.py +++ b/apps/api/plane/app/permissions/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .workspace import ( WorkSpaceBasePermission, WorkspaceOwnerPermission, diff --git a/apps/api/plane/app/permissions/base.py b/apps/api/plane/app/permissions/base.py index a2b1a18ff..9c451ed86 100644 --- a/apps/api/plane/app/permissions/base.py +++ b/apps/api/plane/app/permissions/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from plane.db.models import WorkspaceMember, ProjectMember from functools import wraps from rest_framework.response import Response @@ -18,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) diff --git a/apps/api/plane/app/permissions/page.py b/apps/api/plane/app/permissions/page.py index bea878f4c..844ff4daf 100644 --- a/apps/api/plane/app/permissions/page.py +++ b/apps/api/plane/app/permissions/page.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from plane.db.models import ProjectMember, Page from plane.app.permissions import ROLE diff --git a/apps/api/plane/app/permissions/project.py b/apps/api/plane/app/permissions/project.py index a8c0f92a2..55550b27a 100644 --- a/apps/api/plane/app/permissions/project.py +++ b/apps/api/plane/app/permissions/project.py @@ -1,3 +1,7 @@ +# 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.permissions import SAFE_METHODS, BasePermission diff --git a/apps/api/plane/app/permissions/workspace.py b/apps/api/plane/app/permissions/workspace.py index 8dc791c0c..ada16ec3b 100644 --- a/apps/api/plane/app/permissions/workspace.py +++ b/apps/api/plane/app/permissions/workspace.py @@ -1,3 +1,7 @@ +# 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.permissions import BasePermission, SAFE_METHODS diff --git a/apps/api/plane/app/serializers/__init__.py b/apps/api/plane/app/serializers/__init__.py index 759f27ed6..e8a4007ea 100644 --- a/apps/api/plane/app/serializers/__init__.py +++ b/apps/api/plane/app/serializers/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .base import BaseSerializer from .user import ( UserSerializer, @@ -52,7 +56,7 @@ from .issue import ( IssueCreateSerializer, IssueActivitySerializer, IssueCommentSerializer, - IssueUserPropertySerializer, + ProjectUserPropertySerializer, IssueAssigneeSerializer, LabelSerializer, IssueSerializer, diff --git a/apps/api/plane/app/serializers/analytic.py b/apps/api/plane/app/serializers/analytic.py index 13b24d14d..ca86e569f 100644 --- a/apps/api/plane/app/serializers/analytic.py +++ b/apps/api/plane/app/serializers/analytic.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .base import BaseSerializer from plane.db.models import AnalyticView from plane.utils.issue_filters import issue_filters diff --git a/apps/api/plane/app/serializers/api.py b/apps/api/plane/app/serializers/api.py index d14dcacff..05c6198f5 100644 --- a/apps/api/plane/app/serializers/api.py +++ b/apps/api/plane/app/serializers/api.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .base import BaseSerializer from plane.db.models import APIToken, APIActivityLog from rest_framework import serializers diff --git a/apps/api/plane/app/serializers/asset.py b/apps/api/plane/app/serializers/asset.py index 560cd3538..1de596101 100644 --- a/apps/api/plane/app/serializers/asset.py +++ b/apps/api/plane/app/serializers/asset.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .base import BaseSerializer from plane.db.models import FileAsset diff --git a/apps/api/plane/app/serializers/base.py b/apps/api/plane/app/serializers/base.py index 0d8c855c9..6457eec50 100644 --- a/apps/api/plane/app/serializers/base.py +++ b/apps/api/plane/app/serializers/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from rest_framework import serializers diff --git a/apps/api/plane/app/serializers/cycle.py b/apps/api/plane/app/serializers/cycle.py index 89a5efc06..afdc58116 100644 --- a/apps/api/plane/app/serializers/cycle.py +++ b/apps/api/plane/app/serializers/cycle.py @@ -1,3 +1,7 @@ +# 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 import serializers diff --git a/apps/api/plane/app/serializers/draft.py b/apps/api/plane/app/serializers/draft.py index b017a03ba..da6eae6e2 100644 --- a/apps/api/plane/app/serializers/draft.py +++ b/apps/api/plane/app/serializers/draft.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.utils import timezone diff --git a/apps/api/plane/app/serializers/estimate.py b/apps/api/plane/app/serializers/estimate.py index b2d65ef8c..d3343fbe8 100644 --- a/apps/api/plane/app/serializers/estimate.py +++ b/apps/api/plane/app/serializers/estimate.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from .base import BaseSerializer diff --git a/apps/api/plane/app/serializers/exporter.py b/apps/api/plane/app/serializers/exporter.py index 5c78cfa69..f8efcfce1 100644 --- a/apps/api/plane/app/serializers/exporter.py +++ b/apps/api/plane/app/serializers/exporter.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from .base import BaseSerializer from plane.db.models import ExporterHistory diff --git a/apps/api/plane/app/serializers/favorite.py b/apps/api/plane/app/serializers/favorite.py index 246461f8f..023c7d5d5 100644 --- a/apps/api/plane/app/serializers/favorite.py +++ b/apps/api/plane/app/serializers/favorite.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from rest_framework import serializers from plane.db.models import UserFavorite, Cycle, Module, Issue, IssueView, Page, Project diff --git a/apps/api/plane/app/serializers/importer.py b/apps/api/plane/app/serializers/importer.py index 8997f6392..2dc4e8e72 100644 --- a/apps/api/plane/app/serializers/importer.py +++ b/apps/api/plane/app/serializers/importer.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from .base import BaseSerializer from .user import UserLiteSerializer diff --git a/apps/api/plane/app/serializers/intake.py b/apps/api/plane/app/serializers/intake.py index bc75a0ce5..4037dfe1c 100644 --- a/apps/api/plane/app/serializers/intake.py +++ b/apps/api/plane/app/serializers/intake.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Third party frameworks from rest_framework import serializers diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index 5e3b93ab6..c5af9b9ff 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.utils import timezone from django.core.validators import URLValidator @@ -18,7 +22,7 @@ from plane.db.models import ( Issue, IssueActivity, IssueComment, - IssueUserProperty, + ProjectUserProperty, IssueAssignee, IssueSubscriber, IssueLabel, @@ -53,7 +57,7 @@ class IssueFlatSerializer(BaseSerializer): fields = [ "id", "name", - "description", + "description_json", "description_html", "priority", "start_date", @@ -346,9 +350,9 @@ class IssueActivitySerializer(BaseSerializer): fields = "__all__" -class IssueUserPropertySerializer(BaseSerializer): +class ProjectUserPropertySerializer(BaseSerializer): class Meta: - model = IssueUserProperty + model = ProjectUserProperty fields = "__all__" read_only_fields = ["user", "workspace", "project"] diff --git a/apps/api/plane/app/serializers/module.py b/apps/api/plane/app/serializers/module.py index b5e2953cc..7d01284e3 100644 --- a/apps/api/plane/app/serializers/module.py +++ b/apps/api/plane/app/serializers/module.py @@ -1,3 +1,7 @@ +# 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 import serializers diff --git a/apps/api/plane/app/serializers/notification.py b/apps/api/plane/app/serializers/notification.py index 58007ec26..b4eb4eac5 100644 --- a/apps/api/plane/app/serializers/notification.py +++ b/apps/api/plane/app/serializers/notification.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from .base import BaseSerializer from .user import UserLiteSerializer diff --git a/apps/api/plane/app/serializers/page.py b/apps/api/plane/app/serializers/page.py index 3aecbafda..a9251129c 100644 --- a/apps/api/plane/app/serializers/page.py +++ b/apps/api/plane/app/serializers/page.py @@ -1,3 +1,7 @@ +# 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 import serializers import base64 @@ -58,7 +62,7 @@ class PageSerializer(BaseSerializer): labels = validated_data.pop("labels", None) project_id = self.context["project_id"] owned_by_id = self.context["owned_by_id"] - description = self.context["description"] + description_json = self.context["description_json"] description_binary = self.context["description_binary"] description_html = self.context["description_html"] @@ -68,7 +72,7 @@ class PageSerializer(BaseSerializer): # Create the page page = Page.objects.create( **validated_data, - description=description, + description_json=description_json, description_binary=description_binary, description_html=description_html, owned_by_id=owned_by_id, @@ -171,7 +175,7 @@ class PageBinaryUpdateSerializer(serializers.Serializer): description_binary = serializers.CharField(required=False, allow_blank=True) description_html = serializers.CharField(required=False, allow_blank=True) - description = serializers.JSONField(required=False, allow_null=True) + description_json = serializers.JSONField(required=False, allow_null=True) def validate_description_binary(self, value): """Validate the base64-encoded binary data""" @@ -214,8 +218,8 @@ class PageBinaryUpdateSerializer(serializers.Serializer): if "description_html" in validated_data: instance.description_html = validated_data.get("description_html") - if "description" in validated_data: - instance.description = validated_data.get("description") + if "description_json" in validated_data: + instance.description_json = validated_data.get("description_json") instance.save() return instance diff --git a/apps/api/plane/app/serializers/project.py b/apps/api/plane/app/serializers/project.py index 01569cbc9..924c48fcf 100644 --- a/apps/api/plane/app/serializers/project.py +++ b/apps/api/plane/app/serializers/project.py @@ -1,8 +1,16 @@ +# 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 import serializers +# Python imports +import re + # Module imports from .base import BaseSerializer, DynamicBaseSerializer +from django.db.models import Max from plane.app.serializers.workspace import WorkspaceLiteSerializer from plane.app.serializers.user import UserLiteSerializer, UserAdminLiteSerializer from plane.db.models import ( @@ -12,6 +20,7 @@ from plane.db.models import ( ProjectIdentifier, DeployBoard, ProjectPublicMember, + IssueSequence, ) from plane.utils.content_validator import ( validate_html_content, @@ -31,6 +40,9 @@ class ProjectSerializer(BaseSerializer): project_id = self.instance.id if self.instance else None workspace_id = self.context["workspace_id"] + if re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, name): + raise serializers.ValidationError(detail="PROJECT_NAME_CANNOT_CONTAIN_SPECIAL_CHARACTERS") + project = Project.objects.filter(name=name, workspace_id=workspace_id) if project_id: @@ -47,6 +59,9 @@ class ProjectSerializer(BaseSerializer): project_id = self.instance.id if self.instance else None workspace_id = self.context["workspace_id"] + if re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, identifier): + raise serializers.ValidationError(detail="PROJECT_IDENTIFIER_CANNOT_CONTAIN_SPECIAL_CHARACTERS") + project = Project.objects.filter(identifier=identifier, workspace_id=workspace_id) if project_id: @@ -105,6 +120,7 @@ class ProjectListSerializer(DynamicBaseSerializer): members = serializers.SerializerMethodField() cover_image_url = serializers.CharField(read_only=True) inbox_view = serializers.BooleanField(read_only=True, source="intake_view") + next_work_item_sequence = serializers.SerializerMethodField() def get_members(self, obj): project_members = getattr(obj, "members_list", None) @@ -113,6 +129,11 @@ class ProjectListSerializer(DynamicBaseSerializer): return [member.member_id for member in project_members if member.is_active and not member.member.is_bot] return [] + def get_next_work_item_sequence(self, obj): + """Get the next sequence ID that will be assigned to a new issue""" + max_sequence = IssueSequence.objects.filter(project_id=obj.id).aggregate(max_seq=Max("sequence"))["max_seq"] + return (max_sequence + 1) if max_sequence else 1 + class Meta: model = Project fields = "__all__" diff --git a/apps/api/plane/app/serializers/state.py b/apps/api/plane/app/serializers/state.py index cb56cfbe9..0e333a80b 100644 --- a/apps/api/plane/app/serializers/state.py +++ b/apps/api/plane/app/serializers/state.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from .base import BaseSerializer from rest_framework import serializers diff --git a/apps/api/plane/app/serializers/user.py b/apps/api/plane/app/serializers/user.py index 670667a85..aeef4ee28 100644 --- a/apps/api/plane/app/serializers/user.py +++ b/apps/api/plane/app/serializers/user.py @@ -1,3 +1,7 @@ +# 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 import serializers @@ -78,6 +82,7 @@ class UserMeSerializer(BaseSerializer): "is_password_autoset", "is_email_verified", "last_login_medium", + "last_login_time", ] read_only_fields = fields diff --git a/apps/api/plane/app/serializers/view.py b/apps/api/plane/app/serializers/view.py index bf7ff9727..72f72ff71 100644 --- a/apps/api/plane/app/serializers/view.py +++ b/apps/api/plane/app/serializers/view.py @@ -1,3 +1,7 @@ +# 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 import serializers diff --git a/apps/api/plane/app/serializers/webhook.py b/apps/api/plane/app/serializers/webhook.py index 2aecebcde..74ebde892 100644 --- a/apps/api/plane/app/serializers/webhook.py +++ b/apps/api/plane/app/serializers/webhook.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import socket import ipaddress diff --git a/apps/api/plane/app/serializers/workspace.py b/apps/api/plane/app/serializers/workspace.py index ba59f2429..608cdad85 100644 --- a/apps/api/plane/app/serializers/workspace.py +++ b/apps/api/plane/app/serializers/workspace.py @@ -1,3 +1,7 @@ +# 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 import serializers @@ -107,7 +111,7 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer): invite_link = serializers.SerializerMethodField() def get_invite_link(self, obj): - return f"/workspace-invitations/?invitation_id={obj.id}&email={obj.email}&slug={obj.workspace.slug}" + return f"/workspace-invitations/?invitation_id={obj.id}&slug={obj.workspace.slug}&token={obj.token}" class Meta: model = WorkspaceMemberInvite diff --git a/apps/api/plane/app/urls/__init__.py b/apps/api/plane/app/urls/__init__.py index 3feab4cb5..3fa850b6a 100644 --- a/apps/api/plane/app/urls/__init__.py +++ b/apps/api/plane/app/urls/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .analytic import urlpatterns as analytic_urls from .api import urlpatterns as api_urls from .asset import urlpatterns as asset_urls diff --git a/apps/api/plane/app/urls/analytic.py b/apps/api/plane/app/urls/analytic.py index df6ad2498..2b3194186 100644 --- a/apps/api/plane/app/urls/analytic.py +++ b/apps/api/plane/app/urls/analytic.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/app/urls/api.py b/apps/api/plane/app/urls/api.py index c74aeddbf..145cfdd65 100644 --- a/apps/api/plane/app/urls/api.py +++ b/apps/api/plane/app/urls/api.py @@ -1,5 +1,9 @@ +# 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.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint +from plane.app.views import ApiTokenEndpoint urlpatterns = [ # API Tokens @@ -13,10 +17,5 @@ urlpatterns = [ ApiTokenEndpoint.as_view(), name="api-tokens-details", ), - path( - "workspaces//service-api-tokens/", - ServiceApiTokenEndpoint.as_view(), - name="service-api-tokens", - ), ## End API Tokens ] diff --git a/apps/api/plane/app/urls/asset.py b/apps/api/plane/app/urls/asset.py index 4b7e2b220..fd8d20073 100644 --- a/apps/api/plane/app/urls/asset.py +++ b/apps/api/plane/app/urls/asset.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/app/urls/cycle.py b/apps/api/plane/app/urls/cycle.py index f188d0872..2560a3edb 100644 --- a/apps/api/plane/app/urls/cycle.py +++ b/apps/api/plane/app/urls/cycle.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/app/urls/estimate.py b/apps/api/plane/app/urls/estimate.py index c77a5b6b6..4378164f1 100644 --- a/apps/api/plane/app/urls/estimate.py +++ b/apps/api/plane/app/urls/estimate.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/app/urls/exporter.py b/apps/api/plane/app/urls/exporter.py index 0bcb4621b..c7acf5343 100644 --- a/apps/api/plane/app/urls/exporter.py +++ b/apps/api/plane/app/urls/exporter.py @@ -1,3 +1,7 @@ +# 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.app.views import ExportIssuesEndpoint @@ -9,4 +13,4 @@ urlpatterns = [ ExportIssuesEndpoint.as_view(), name="export-issues", ), -] \ No newline at end of file +] diff --git a/apps/api/plane/app/urls/external.py b/apps/api/plane/app/urls/external.py index 4972962d8..1255ac08a 100644 --- a/apps/api/plane/app/urls/external.py +++ b/apps/api/plane/app/urls/external.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/app/urls/intake.py b/apps/api/plane/app/urls/intake.py index dd1efc872..970e763d6 100644 --- a/apps/api/plane/app/urls/intake.py +++ b/apps/api/plane/app/urls/intake.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/app/urls/issue.py b/apps/api/plane/app/urls/issue.py index 1d809e248..436d22770 100644 --- a/apps/api/plane/app/urls/issue.py +++ b/apps/api/plane/app/urls/issue.py @@ -1,3 +1,7 @@ +# 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.app.views import ( @@ -14,7 +18,7 @@ from plane.app.views import ( IssueReactionViewSet, IssueRelationViewSet, IssueSubscriberViewSet, - IssueUserDisplayPropertyEndpoint, + ProjectUserDisplayPropertyEndpoint, IssueViewSet, LabelViewSet, BulkArchiveIssuesEndpoint, @@ -208,13 +212,13 @@ urlpatterns = [ name="project-issue-comment-reactions", ), ## End Comment Reactions - ## IssueUserProperty + ## ProjectUserProperty path( "workspaces//projects//user-properties/", - IssueUserDisplayPropertyEndpoint.as_view(), + ProjectUserDisplayPropertyEndpoint.as_view(), name="project-issue-display-properties", ), - ## IssueUserProperty End + ## ProjectUserProperty End ## Issue Archives path( "workspaces//projects//archived-issues/", diff --git a/apps/api/plane/app/urls/module.py b/apps/api/plane/app/urls/module.py index 75cbb14d6..255f8211c 100644 --- a/apps/api/plane/app/urls/module.py +++ b/apps/api/plane/app/urls/module.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/app/urls/notification.py b/apps/api/plane/app/urls/notification.py index 0c992d49e..cd2b3c5a4 100644 --- a/apps/api/plane/app/urls/notification.py +++ b/apps/api/plane/app/urls/notification.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/app/urls/page.py b/apps/api/plane/app/urls/page.py index 8cac22a2f..dd4395c18 100644 --- a/apps/api/plane/app/urls/page.py +++ b/apps/api/plane/app/urls/page.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/app/urls/project.py b/apps/api/plane/app/urls/project.py index a6dd8d8a8..ee850af1d 100644 --- a/apps/api/plane/app/urls/project.py +++ b/apps/api/plane/app/urls/project.py @@ -1,3 +1,7 @@ +# 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.app.views import ( diff --git a/apps/api/plane/app/urls/search.py b/apps/api/plane/app/urls/search.py index 0bbbd9cf7..9d94aa273 100644 --- a/apps/api/plane/app/urls/search.py +++ b/apps/api/plane/app/urls/search.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/app/urls/state.py b/apps/api/plane/app/urls/state.py index b6135ca95..902b583cb 100644 --- a/apps/api/plane/app/urls/state.py +++ b/apps/api/plane/app/urls/state.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/app/urls/timezone.py b/apps/api/plane/app/urls/timezone.py index ff14d029f..9fc17f79a 100644 --- a/apps/api/plane/app/urls/timezone.py +++ b/apps/api/plane/app/urls/timezone.py @@ -1,3 +1,7 @@ +# 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.app.views import TimezoneEndpoint diff --git a/apps/api/plane/app/urls/user.py b/apps/api/plane/app/urls/user.py index 373d4a70d..bc110a28d 100644 --- a/apps/api/plane/app/urls/user.py +++ b/apps/api/plane/app/urls/user.py @@ -1,3 +1,7 @@ +# 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.app.views import ( diff --git a/apps/api/plane/app/urls/views.py b/apps/api/plane/app/urls/views.py index 063e39c3d..f3e4ee1de 100644 --- a/apps/api/plane/app/urls/views.py +++ b/apps/api/plane/app/urls/views.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/app/urls/webhook.py b/apps/api/plane/app/urls/webhook.py index e21ec7261..22ac4bc6f 100644 --- a/apps/api/plane/app/urls/webhook.py +++ b/apps/api/plane/app/urls/webhook.py @@ -1,3 +1,7 @@ +# 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.app.views import ( diff --git a/apps/api/plane/app/urls/workspace.py b/apps/api/plane/app/urls/workspace.py index 5f781efa7..d79d5a745 100644 --- a/apps/api/plane/app/urls/workspace.py +++ b/apps/api/plane/app/urls/workspace.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 7a0e5cb3a..84f7872ec 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .project.base import ( ProjectViewSet, ProjectIdentifierEndpoint, @@ -114,7 +118,7 @@ from .asset.v2 import ( from .issue.base import ( IssueListEndpoint, IssueViewSet, - IssueUserDisplayPropertyEndpoint, + ProjectUserDisplayPropertyEndpoint, BulkDeleteIssuesEndpoint, DeletedIssuesListViewSet, IssuePaginatedViewSet, @@ -161,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, diff --git a/apps/api/plane/app/views/analytic/advance.py b/apps/api/plane/app/views/analytic/advance.py index 1a5b1b34c..5ba9a439b 100644 --- a/apps/api/plane/app/views/analytic/advance.py +++ b/apps/api/plane/app/views/analytic/advance.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from rest_framework.response import Response from rest_framework import status from typing import Dict, List, Any diff --git a/apps/api/plane/app/views/analytic/base.py b/apps/api/plane/app/views/analytic/base.py index 6e9311a18..2f3f8b573 100644 --- a/apps/api/plane/app/views/analytic/base.py +++ b/apps/api/plane/app/views/analytic/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.db.models import Count, F, Sum, Q from django.db.models.functions import ExtractMonth diff --git a/apps/api/plane/app/views/analytic/project_analytics.py b/apps/api/plane/app/views/analytic/project_analytics.py index 2529900b0..c8e896716 100644 --- a/apps/api/plane/app/views/analytic/project_analytics.py +++ b/apps/api/plane/app/views/analytic/project_analytics.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from rest_framework.response import Response from rest_framework import status from typing import Dict, Any diff --git a/apps/api/plane/app/views/api.py b/apps/api/plane/app/views/api.py index 419859902..f3163c331 100644 --- a/apps/api/plane/app/views/api.py +++ b/apps/api/plane/app/views/api.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python import from uuid import uuid4 from typing import Optional @@ -9,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): @@ -57,28 +60,3 @@ class ApiTokenEndpoint(BaseAPIView): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -class ServiceApiTokenEndpoint(BaseAPIView): - permission_classes = [WorkspaceEntityPermission] - - def post(self, request: Request, slug: str) -> Response: - workspace = Workspace.objects.get(slug=slug) - - api_token = APIToken.objects.filter(workspace=workspace, is_service=True).first() - - if api_token: - return Response({"token": str(api_token.token)}, status=status.HTTP_200_OK) - else: - # Check the user type - user_type = 1 if request.user.is_bot else 0 - - api_token = APIToken.objects.create( - label=str(uuid4().hex), - description="Service Token", - user=request.user, - workspace=workspace, - user_type=user_type, - is_service=True, - ) - return Response({"token": str(api_token.token)}, status=status.HTTP_201_CREATED) diff --git a/apps/api/plane/app/views/asset/base.py b/apps/api/plane/app/views/asset/base.py index 522d4af75..5b55a76a6 100644 --- a/apps/api/plane/app/views/asset/base.py +++ b/apps/api/plane/app/views/asset/base.py @@ -1,3 +1,7 @@ +# 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 import status from rest_framework.response import Response diff --git a/apps/api/plane/app/views/asset/v2.py b/apps/api/plane/app/views/asset/v2.py index 3677537bb..62c5f84a2 100644 --- a/apps/api/plane/app/views/asset/v2.py +++ b/apps/api/plane/app/views/asset/v2.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import uuid @@ -45,9 +49,7 @@ class UserAssetsV2Endpoint(BaseAPIView): # Save the new avatar user.avatar_asset_id = asset_id user.save() - invalidate_cache_directly( - path="/api/users/me/", url_params=False, user=True, request=request - ) + invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request) invalidate_cache_directly( path="/api/users/me/settings/", url_params=False, @@ -65,9 +67,7 @@ class UserAssetsV2Endpoint(BaseAPIView): # Save the new cover image user.cover_image_asset_id = asset_id user.save() - invalidate_cache_directly( - path="/api/users/me/", url_params=False, user=True, request=request - ) + invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request) invalidate_cache_directly( path="/api/users/me/settings/", url_params=False, @@ -83,9 +83,7 @@ class UserAssetsV2Endpoint(BaseAPIView): user = User.objects.get(id=asset.user_id) user.avatar_asset_id = None user.save() - invalidate_cache_directly( - path="/api/users/me/", url_params=False, user=True, request=request - ) + invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request) invalidate_cache_directly( path="/api/users/me/settings/", url_params=False, @@ -98,9 +96,7 @@ class UserAssetsV2Endpoint(BaseAPIView): user = User.objects.get(id=asset.user_id) user.cover_image_asset_id = None user.save() - invalidate_cache_directly( - path="/api/users/me/", url_params=False, user=True, request=request - ) + invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request) invalidate_cache_directly( path="/api/users/me/settings/", url_params=False, @@ -160,9 +156,7 @@ class UserAssetsV2Endpoint(BaseAPIView): # Get the presigned URL storage = S3Storage(request=request) # Generate a presigned URL to share an S3 object - presigned_url = storage.generate_presigned_post( - object_name=asset_key, file_type=type, file_size=size_limit - ) + presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit) # Return the presigned URL return Response( { @@ -199,9 +193,7 @@ class UserAssetsV2Endpoint(BaseAPIView): asset.is_deleted = True asset.deleted_at = timezone.now() # get the entity and save the asset id for the request field - self.entity_asset_delete( - entity_type=asset.entity_type, asset=asset, request=request - ) + self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request) asset.save(update_fields=["is_deleted", "deleted_at"]) return Response(status=status.HTTP_204_NO_CONTENT) @@ -265,18 +257,14 @@ class WorkspaceFileAssetEndpoint(BaseAPIView): workspace.logo = "" workspace.logo_asset_id = asset_id workspace.save() - invalidate_cache_directly( - path="/api/workspaces/", url_params=False, user=False, request=request - ) + invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request) invalidate_cache_directly( path="/api/users/me/workspaces/", url_params=False, user=True, request=request, ) - invalidate_cache_directly( - path="/api/instances/", url_params=False, user=False, request=request - ) + invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request) return # Project Cover @@ -303,18 +291,14 @@ class WorkspaceFileAssetEndpoint(BaseAPIView): return workspace.logo_asset_id = None workspace.save() - invalidate_cache_directly( - path="/api/workspaces/", url_params=False, user=False, request=request - ) + invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request) invalidate_cache_directly( path="/api/users/me/workspaces/", url_params=False, user=True, request=request, ) - invalidate_cache_directly( - path="/api/instances/", url_params=False, user=False, request=request - ) + invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request) return # Project Cover elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: @@ -375,17 +359,13 @@ class WorkspaceFileAssetEndpoint(BaseAPIView): workspace=workspace, created_by=request.user, entity_type=entity_type, - **self.get_entity_id_field( - entity_type=entity_type, entity_id=entity_identifier - ), + **self.get_entity_id_field(entity_type=entity_type, entity_id=entity_identifier), ) # Get the presigned URL storage = S3Storage(request=request) # Generate a presigned URL to share an S3 object - presigned_url = storage.generate_presigned_post( - object_name=asset_key, file_type=type, file_size=size_limit - ) + presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit) # Return the presigned URL return Response( { @@ -422,9 +402,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView): asset.is_deleted = True asset.deleted_at = timezone.now() # get the entity and save the asset id for the request field - self.entity_asset_delete( - entity_type=asset.entity_type, asset=asset, request=request - ) + self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request) asset.save(update_fields=["is_deleted", "deleted_at"]) return Response(status=status.HTTP_204_NO_CONTENT) @@ -587,9 +565,7 @@ class ProjectAssetEndpoint(BaseAPIView): # Get the presigned URL storage = S3Storage(request=request) # Generate a presigned URL to share an S3 object - presigned_url = storage.generate_presigned_post( - object_name=asset_key, file_type=type, file_size=size_limit - ) + presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit) # Return the presigned URL return Response( { @@ -619,9 +595,7 @@ class ProjectAssetEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def delete(self, request, slug, project_id, pk): # Get the asset - asset = FileAsset.objects.get( - id=pk, workspace__slug=slug, project_id=project_id - ) + asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id) # Check deleted assets asset.is_deleted = True asset.deleted_at = timezone.now() @@ -632,9 +606,7 @@ class ProjectAssetEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, pk): # get the asset id - asset = FileAsset.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + asset = FileAsset.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) # Check if the asset is uploaded if not asset.is_uploaded: @@ -667,9 +639,7 @@ class ProjectBulkAssetEndpoint(BaseAPIView): # Check if the asset ids are provided if not asset_ids: - return Response( - {"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST) # get the asset id assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug) @@ -723,14 +693,11 @@ class AssetCheckEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def get(self, request, slug, asset_id): - asset = FileAsset.all_objects.filter( - id=asset_id, workspace__slug=slug, deleted_at__isnull=True - ).exists() + asset = FileAsset.all_objects.filter(id=asset_id, workspace__slug=slug, deleted_at__isnull=True).exists() return Response({"exists": asset}, status=status.HTTP_200_OK) class DuplicateAssetEndpoint(BaseAPIView): - throttle_classes = [AssetRateThrottle] def get_entity_id_field(self, entity_type, entity_id): @@ -766,17 +733,13 @@ class DuplicateAssetEndpoint(BaseAPIView): return {} - @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def post(self, request, slug, asset_id): project_id = request.data.get("project_id", None) entity_id = request.data.get("entity_id", None) entity_type = request.data.get("entity_type", None) - - if ( - not entity_type - or entity_type not in FileAsset.EntityTypeContext.values - ): + if not entity_type or entity_type not in FileAsset.EntityTypeContext.values: return Response( {"error": "Invalid entity type or entity id"}, status=status.HTTP_400_BAD_REQUEST, @@ -786,23 +749,15 @@ class DuplicateAssetEndpoint(BaseAPIView): if project_id: # check if project exists in the workspace if not Project.objects.filter(id=project_id, workspace=workspace).exists(): - return Response( - {"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND) storage = S3Storage(request=request) - original_asset = FileAsset.objects.filter( - workspace=workspace, id=asset_id, is_uploaded=True - ).first() + original_asset = FileAsset.objects.filter(id=asset_id, is_uploaded=True).first() if not original_asset: - return Response( - {"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND) - destination_key = ( - f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}" - ) + destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}" duplicated_asset = FileAsset.objects.create( attributes={ "name": original_asset.attributes.get("name"), @@ -822,9 +777,7 @@ class DuplicateAssetEndpoint(BaseAPIView): # Update the is_uploaded field for all newly created assets FileAsset.objects.filter(id=duplicated_asset.id).update(is_uploaded=True) - return Response( - {"asset_id": str(duplicated_asset.id)}, status=status.HTTP_200_OK - ) + return Response({"asset_id": str(duplicated_asset.id)}, status=status.HTTP_200_OK) class WorkspaceAssetDownloadEndpoint(BaseAPIView): diff --git a/apps/api/plane/app/views/base.py b/apps/api/plane/app/views/base.py index 0323302c5..db5469de5 100644 --- a/apps/api/plane/app/views/base.py +++ b/apps/api/plane/app/views/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import traceback diff --git a/apps/api/plane/app/views/cycle/archive.py b/apps/api/plane/app/views/cycle/archive.py index a2f89d53f..3738b3367 100644 --- a/apps/api/plane/app/views/cycle/archive.py +++ b/apps/api/plane/app/views/cycle/archive.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField diff --git a/apps/api/plane/app/views/cycle/base.py b/apps/api/plane/app/views/cycle/base.py index 712d71754..30a5732ce 100644 --- a/apps/api/plane/app/views/cycle/base.py +++ b/apps/api/plane/app/views/cycle/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json import pytz @@ -97,9 +101,7 @@ class CycleViewSet(BaseViewSet): .prefetch_related( Prefetch( "issue_cycle__issue__assignees", - queryset=User.objects.only( - "avatar_asset", "first_name", "id" - ).distinct(), + queryset=User.objects.only("avatar_asset", "first_name", "id").distinct(), ) ) .prefetch_related( @@ -150,8 +152,7 @@ class CycleViewSet(BaseViewSet): .annotate( status=Case( When( - Q(start_date__lte=current_time_in_utc) - & Q(end_date__gte=current_time_in_utc), + Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc), then=Value("CURRENT"), ), When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")), @@ -170,11 +171,7 @@ class CycleViewSet(BaseViewSet): "issue_cycle__issue__assignees__id", distinct=True, filter=~Q(issue_cycle__issue__assignees__id__isnull=True) - & ( - Q( - issue_cycle__issue__issue_assignee__deleted_at__isnull=True - ) - ), + & (Q(issue_cycle__issue__issue_assignee__deleted_at__isnull=True)), ), Value([], output_field=ArrayField(UUIDField())), ) @@ -205,9 +202,7 @@ class CycleViewSet(BaseViewSet): # Current Cycle if cycle_view == "current": - queryset = queryset.filter( - start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc - ) + queryset = queryset.filter(start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc) data = queryset.values( # necessary fields @@ -274,16 +269,10 @@ class CycleViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id): - if ( - request.data.get("start_date", None) is None - and request.data.get("end_date", None) is None - ) or ( - request.data.get("start_date", None) is not None - and request.data.get("end_date", None) is not None + if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or ( + request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None ): - serializer = CycleWriteSerializer( - data=request.data, context={"project_id": project_id} - ) + serializer = CycleWriteSerializer(data=request.data, context={"project_id": project_id}) if serializer.is_valid(): serializer.save(project_id=project_id, owned_by=request.user) cycle = ( @@ -323,9 +312,7 @@ class CycleViewSet(BaseViewSet): project_timezone = project.timezone datetime_fields = ["start_date", "end_date"] - cycle = user_timezone_converter( - cycle, datetime_fields, project_timezone - ) + cycle = user_timezone_converter(cycle, datetime_fields, project_timezone) # Send the model activity model_activity.delay( @@ -341,17 +328,13 @@ class CycleViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) else: return Response( - { - "error": "Both start date and end date are either required or are to be null" - }, + {"error": "Both start date and end date are either required or are to be null"}, status=status.HTTP_400_BAD_REQUEST, ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def partial_update(self, request, slug, project_id, pk): - queryset = self.get_queryset().filter( - workspace__slug=slug, project_id=project_id, pk=pk - ) + queryset = self.get_queryset().filter(workspace__slug=slug, project_id=project_id, pk=pk) cycle = queryset.first() if cycle.archived_at: return Response( @@ -359,29 +342,21 @@ class CycleViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - current_instance = json.dumps( - CycleSerializer(cycle).data, cls=DjangoJSONEncoder - ) + current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder) request_data = request.data if cycle.end_date is not None and cycle.end_date < timezone.now(): if "sort_order" in request_data: # Can only change sort order for a completed cycle`` - request_data = { - "sort_order": request_data.get("sort_order", cycle.sort_order) - } + request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)} else: return Response( - { - "error": "The Cycle has already been completed so it cannot be edited" - }, + {"error": "The Cycle has already been completed so it cannot be edited"}, status=status.HTTP_400_BAD_REQUEST, ) - serializer = CycleWriteSerializer( - cycle, data=request.data, partial=True, context={"project_id": project_id} - ) + serializer = CycleWriteSerializer(cycle, data=request.data, partial=True, context={"project_id": project_id}) if serializer.is_valid(): serializer.save() cycle = queryset.values( @@ -481,9 +456,7 @@ class CycleViewSet(BaseViewSet): ) if data is None: - return Response( - {"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND) queryset = queryset.first() # Fetch the project timezone @@ -505,11 +478,7 @@ class CycleViewSet(BaseViewSet): def destroy(self, request, slug, project_id, pk): cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - cycle_issues = list( - CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( - "issue", flat=True - ) - ) + cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True)) issue_activity.delay( type="cycle.activity.deleted", @@ -560,9 +529,7 @@ class CycleDateCheckEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - start_date = convert_to_utc( - date=str(start_date), project_id=project_id, is_start_date=True - ) + start_date = convert_to_utc(date=str(start_date), project_id=project_id, is_start_date=True) end_date = convert_to_utc( date=str(end_date), project_id=project_id, @@ -666,12 +633,8 @@ class CycleUserPropertiesEndpoint(BaseAPIView): ) cycle_properties.filters = request.data.get("filters", cycle_properties.filters) - cycle_properties.rich_filters = request.data.get( - "rich_filters", cycle_properties.rich_filters - ) - cycle_properties.display_filters = request.data.get( - "display_filters", cycle_properties.display_filters - ) + cycle_properties.rich_filters = request.data.get("rich_filters", cycle_properties.rich_filters) + cycle_properties.display_filters = request.data.get("display_filters", cycle_properties.display_filters) cycle_properties.display_properties = request.data.get( "display_properties", cycle_properties.display_properties ) @@ -695,13 +658,9 @@ class CycleUserPropertiesEndpoint(BaseAPIView): class CycleProgressEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, cycle_id): - cycle = Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, id=cycle_id - ).first() + cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id).first() if not cycle: - return Response( - {"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND) aggregate_estimates = ( Issue.issue_objects.filter( estimate_point__estimate__type="points", @@ -747,9 +706,7 @@ class CycleProgressEndpoint(BaseAPIView): output_field=FloatField(), ) ), - total_estimate_points=Sum( - "value_as_float", default=Value(0), output_field=FloatField() - ), + total_estimate_points=Sum("value_as_float", default=Value(0), output_field=FloatField()), ) ) if cycle.progress_snapshot: @@ -809,22 +766,11 @@ class CycleProgressEndpoint(BaseAPIView): return Response( { - "backlog_estimate_points": aggregate_estimates["backlog_estimate_point"] - or 0, - "unstarted_estimate_points": aggregate_estimates[ - "unstarted_estimate_point" - ] - or 0, - "started_estimate_points": aggregate_estimates["started_estimate_point"] - or 0, - "cancelled_estimate_points": aggregate_estimates[ - "cancelled_estimate_point" - ] - or 0, - "completed_estimate_points": aggregate_estimates[ - "completed_estimate_points" - ] - or 0, + "backlog_estimate_points": aggregate_estimates["backlog_estimate_point"] or 0, + "unstarted_estimate_points": aggregate_estimates["unstarted_estimate_point"] or 0, + "started_estimate_points": aggregate_estimates["started_estimate_point"] or 0, + "cancelled_estimate_points": aggregate_estimates["cancelled_estimate_point"] or 0, + "completed_estimate_points": aggregate_estimates["completed_estimate_points"] or 0, "total_estimate_points": aggregate_estimates["total_estimate_points"], "backlog_issues": backlog_issues, "total_issues": total_issues, @@ -842,9 +788,7 @@ class CycleAnalyticsEndpoint(BaseAPIView): def get(self, request, slug, project_id, cycle_id): analytic_type = request.GET.get("type", "issues") cycle = ( - Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, id=cycle_id - ) + Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id) .annotate( total_issues=Count( "issue_cycle__issue__id", @@ -927,9 +871,7 @@ class CycleAnalyticsEndpoint(BaseAPIView): ) ) .values("display_name", "assignee_id", "avatar_url") - .annotate( - total_estimates=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -964,9 +906,7 @@ class CycleAnalyticsEndpoint(BaseAPIView): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate( - total_estimates=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -1068,11 +1008,7 @@ class CycleAnalyticsEndpoint(BaseAPIView): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate( - total_issues=Count( - "label_id", filter=Q(archived_at__isnull=True, is_draft=False) - ) - ) + .annotate(total_issues=Count("label_id", filter=Q(archived_at__isnull=True, is_draft=False))) .annotate( completed_issues=Count( "label_id", diff --git a/apps/api/plane/app/views/cycle/issue.py b/apps/api/plane/app/views/cycle/issue.py index ad3923b17..609967845 100644 --- a/apps/api/plane/app/views/cycle/issue.py +++ b/apps/api/plane/app/views/cycle/issue.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import copy import json diff --git a/apps/api/plane/app/views/error_404.py b/apps/api/plane/app/views/error_404.py index 97c3c59f7..b7ec4dcf3 100644 --- a/apps/api/plane/app/views/error_404.py +++ b/apps/api/plane/app/views/error_404.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # views.py from django.http import JsonResponse diff --git a/apps/api/plane/app/views/estimate/base.py b/apps/api/plane/app/views/estimate/base.py index f54115a4f..4bdc86633 100644 --- a/apps/api/plane/app/views/estimate/base.py +++ b/apps/api/plane/app/views/estimate/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import random import string import json diff --git a/apps/api/plane/app/views/exporter/base.py b/apps/api/plane/app/views/exporter/base.py index 5f446ff94..64364ecf4 100644 --- a/apps/api/plane/app/views/exporter/base.py +++ b/apps/api/plane/app/views/exporter/base.py @@ -1,3 +1,7 @@ +# 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 import status from rest_framework.response import Response diff --git a/apps/api/plane/app/views/external/base.py b/apps/api/plane/app/views/external/base.py index 2c554bbc8..013bad2db 100644 --- a/apps/api/plane/app/views/external/base.py +++ b/apps/api/plane/app/views/external/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python import import os from typing import List, Dict, Tuple diff --git a/apps/api/plane/app/views/intake/base.py b/apps/api/plane/app/views/intake/base.py index 7dd7828cb..d4049aa3c 100644 --- a/apps/api/plane/app/views/intake/base.py +++ b/apps/api/plane/app/views/intake/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json @@ -394,7 +398,7 @@ class IntakeIssueViewSet(BaseViewSet): issue_data = { "name": issue_data.get("name", issue.name), "description_html": issue_data.get("description_html", issue.description_html), - "description": issue_data.get("description", issue.description), + "description_json": issue_data.get("description_json", issue.description_json), } issue_current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder) diff --git a/apps/api/plane/app/views/issue/activity.py b/apps/api/plane/app/views/issue/activity.py index fdfcd129a..8f6295640 100644 --- a/apps/api/plane/app/views/issue/activity.py +++ b/apps/api/plane/app/views/issue/activity.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports from itertools import chain diff --git a/apps/api/plane/app/views/issue/archive.py b/apps/api/plane/app/views/issue/archive.py index b8f858969..1ac808cf9 100644 --- a/apps/api/plane/app/views/issue/archive.py +++ b/apps/api/plane/app/views/issue/archive.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import copy import json diff --git a/apps/api/plane/app/views/issue/attachment.py b/apps/api/plane/app/views/issue/attachment.py index 2207d2419..df027c413 100644 --- a/apps/api/plane/app/views/issue/attachment.py +++ b/apps/api/plane/app/views/issue/attachment.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json import uuid diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index 7a5e7dddf..bb331802c 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import copy import json @@ -34,7 +38,7 @@ from plane.app.serializers import ( IssueDetailSerializer, IssueListDetailSerializer, IssueSerializer, - IssueUserPropertySerializer, + ProjectUserPropertySerializer, ) from plane.bgtasks.issue_activities_task import issue_activity from plane.bgtasks.issue_description_version_task import issue_description_version_task @@ -51,7 +55,7 @@ from plane.db.models import ( IssueReaction, IssueRelation, IssueSubscriber, - IssueUserProperty, + ProjectUserProperty, ModuleIssue, Project, ProjectMember, @@ -723,23 +727,33 @@ class IssueViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class IssueUserDisplayPropertyEndpoint(BaseAPIView): +class ProjectUserDisplayPropertyEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def patch(self, request, slug, project_id): - issue_property = IssueUserProperty.objects.get(user=request.user, project_id=project_id) + try: + issue_property = ProjectUserProperty.objects.get( + user=request.user, + project_id=project_id + ) + except ProjectUserProperty.DoesNotExist: + issue_property = ProjectUserProperty.objects.create( + user=request.user, + project_id=project_id + ) - issue_property.rich_filters = request.data.get("rich_filters", issue_property.rich_filters) - issue_property.filters = request.data.get("filters", issue_property.filters) - issue_property.display_filters = request.data.get("display_filters", issue_property.display_filters) - issue_property.display_properties = request.data.get("display_properties", issue_property.display_properties) - issue_property.save() - serializer = IssueUserPropertySerializer(issue_property) - return Response(serializer.data, status=status.HTTP_201_CREATED) + serializer = ProjectUserPropertySerializer( + issue_property, + data=request.data, + partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id): - issue_property, _ = IssueUserProperty.objects.get_or_create(user=request.user, project_id=project_id) - serializer = IssueUserPropertySerializer(issue_property) + issue_property, _ = ProjectUserProperty.objects.get_or_create(user=request.user, project_id=project_id) + serializer = ProjectUserPropertySerializer(issue_property) return Response(serializer.data, status=status.HTTP_200_OK) @@ -1104,7 +1118,7 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView): epoch = int(timezone.now().timestamp()) # Fetch all relevant issues in a single query - issues = list(Issue.objects.filter(id__in=issue_ids)) + issues = list(Issue.objects.filter(id__in=issue_ids, workspace__slug=slug, project_id=project_id)) issues_dict = {str(issue.id): issue for issue in issues} issues_to_update = [] diff --git a/apps/api/plane/app/views/issue/comment.py b/apps/api/plane/app/views/issue/comment.py index 72a986fea..34fe0f9e4 100644 --- a/apps/api/plane/app/views/issue/comment.py +++ b/apps/api/plane/app/views/issue/comment.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json diff --git a/apps/api/plane/app/views/issue/label.py b/apps/api/plane/app/views/issue/label.py index ad0a29080..05033593e 100644 --- a/apps/api/plane/app/views/issue/label.py +++ b/apps/api/plane/app/views/issue/label.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import random @@ -39,9 +43,7 @@ class LabelViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN]) def create(self, request, slug, project_id): try: - serializer = LabelSerializer( - data=request.data, context={"project_id": project_id} - ) + serializer = LabelSerializer(data=request.data, context={"project_id": project_id}) if serializer.is_valid(): serializer.save(project_id=project_id) return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/apps/api/plane/app/views/issue/link.py b/apps/api/plane/app/views/issue/link.py index ee209f9fa..549021230 100644 --- a/apps/api/plane/app/views/issue/link.py +++ b/apps/api/plane/app/views/issue/link.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json diff --git a/apps/api/plane/app/views/issue/reaction.py b/apps/api/plane/app/views/issue/reaction.py index fe8a63b13..c09e1e924 100644 --- a/apps/api/plane/app/views/issue/reaction.py +++ b/apps/api/plane/app/views/issue/reaction.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json diff --git a/apps/api/plane/app/views/issue/relation.py b/apps/api/plane/app/views/issue/relation.py index 0dfd686d2..e91ddffec 100644 --- a/apps/api/plane/app/views/issue/relation.py +++ b/apps/api/plane/app/views/issue/relation.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json diff --git a/apps/api/plane/app/views/issue/sub_issue.py b/apps/api/plane/app/views/issue/sub_issue.py index 2fa244dcf..b52e07564 100644 --- a/apps/api/plane/app/views/issue/sub_issue.py +++ b/apps/api/plane/app/views/issue/sub_issue.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json diff --git a/apps/api/plane/app/views/issue/subscriber.py b/apps/api/plane/app/views/issue/subscriber.py index 58f3ba4c7..c9a1a29b6 100644 --- a/apps/api/plane/app/views/issue/subscriber.py +++ b/apps/api/plane/app/views/issue/subscriber.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/app/views/issue/version.py b/apps/api/plane/app/views/issue/version.py index 358271ac8..540c7d6d5 100644 --- a/apps/api/plane/app/views/issue/version.py +++ b/apps/api/plane/app/views/issue/version.py @@ -1,3 +1,7 @@ +# 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 import status from rest_framework.response import Response diff --git a/apps/api/plane/app/views/module/archive.py b/apps/api/plane/app/views/module/archive.py index 129ff0dac..1f234d791 100644 --- a/apps/api/plane/app/views/module/archive.py +++ b/apps/api/plane/app/views/module/archive.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.db.models import ( diff --git a/apps/api/plane/app/views/module/base.py b/apps/api/plane/app/views/module/base.py index ae429e750..97e683f75 100644 --- a/apps/api/plane/app/views/module/base.py +++ b/apps/api/plane/app/views/module/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json diff --git a/apps/api/plane/app/views/module/issue.py b/apps/api/plane/app/views/module/issue.py index 672bf4e1a..4707d683a 100644 --- a/apps/api/plane/app/views/module/issue.py +++ b/apps/api/plane/app/views/module/issue.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import copy import json diff --git a/apps/api/plane/app/views/notification/base.py b/apps/api/plane/app/views/notification/base.py index a11c12d16..0b7dc27a8 100644 --- a/apps/api/plane/app/views/notification/base.py +++ b/apps/api/plane/app/views/notification/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.db.models import Exists, OuterRef, Q, Case, When, BooleanField from django.utils import timezone diff --git a/apps/api/plane/app/views/page/base.py b/apps/api/plane/app/views/page/base.py index 50daf440a..ec391afc1 100644 --- a/apps/api/plane/app/views/page/base.py +++ b/apps/api/plane/app/views/page/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json from datetime import datetime @@ -46,7 +50,7 @@ from plane.utils.error_codes import ERROR_CODES # Local imports from ..base import BaseAPIView, BaseViewSet from plane.bgtasks.page_transaction_task import page_transaction -from plane.bgtasks.page_version_task import page_version +from plane.bgtasks.page_version_task import track_page_version from plane.bgtasks.recent_visited_task import recent_visited_task from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets from plane.app.permissions import ProjectPagePermission @@ -128,7 +132,7 @@ class PageViewSet(BaseViewSet): context={ "project_id": project_id, "owned_by_id": request.user.id, - "description": request.data.get("description", {}), + "description_json": request.data.get("description_json", {}), "description_binary": request.data.get("description_binary", None), "description_html": request.data.get("description_html", "

    "), }, @@ -495,14 +499,12 @@ class PagesDescriptionViewSet(BaseViewSet): permission_classes = [ProjectPagePermission] def retrieve(self, request, slug, project_id, page_id): - page = ( - Page.objects.get( - Q(owned_by=self.request.user) | Q(access=0), - pk=page_id, - workspace__slug=slug, - projects__id=project_id, - project_pages__deleted_at__isnull=True, - ) + page = Page.objects.get( + Q(owned_by=self.request.user) | Q(access=0), + pk=page_id, + workspace__slug=slug, + projects__id=project_id, + project_pages__deleted_at__isnull=True, ) binary_data = page.description_binary @@ -517,14 +519,12 @@ class PagesDescriptionViewSet(BaseViewSet): return response def partial_update(self, request, slug, project_id, page_id): - page = ( - Page.objects.get( - Q(owned_by=self.request.user) | Q(access=0), - pk=page_id, - workspace__slug=slug, - projects__id=project_id, - project_pages__deleted_at__isnull=True, - ) + page = Page.objects.get( + Q(owned_by=self.request.user) | Q(access=0), + pk=page_id, + workspace__slug=slug, + projects__id=project_id, + project_pages__deleted_at__isnull=True, ) if page.is_locked: @@ -545,26 +545,28 @@ class PagesDescriptionViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + # Store the old description_html before saving (needed for both tasks) + old_description_html = page.description_html + # Serialize the existing instance - existing_instance = json.dumps({"description_html": page.description_html}, cls=DjangoJSONEncoder) + existing_instance = json.dumps({"description_html": old_description_html}, cls=DjangoJSONEncoder) # Use serializer for validation and update serializer = PageBinaryUpdateSerializer(page, data=request.data, partial=True) if serializer.is_valid(): + serializer.save() + # Capture the page transaction if request.data.get("description_html"): page_transaction.delay( new_description_html=request.data.get("description_html", "

    "), - old_description_html=page.description_html, + old_description_html=old_description_html, page_id=page_id, ) - # Update the page using serializer - updated_page = serializer.save() - # Run background tasks - page_version.delay( - page_id=updated_page.id, + track_page_version.delay( + page_id=page_id, existing_instance=existing_instance, user_id=request.user.id, ) diff --git a/apps/api/plane/app/views/page/version.py b/apps/api/plane/app/views/page/version.py index 1b285c966..e102bf1d0 100644 --- a/apps/api/plane/app/views/page/version.py +++ b/apps/api/plane/app/views/page/version.py @@ -1,3 +1,7 @@ +# 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 import status from rest_framework.response import Response diff --git a/apps/api/plane/app/views/project/base.py b/apps/api/plane/app/views/project/base.py index 3dd1e3db4..0a7378c07 100644 --- a/apps/api/plane/app/views/project/base.py +++ b/apps/api/plane/app/views/project/base.py @@ -1,17 +1,18 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json -import boto3 # Django imports -from django.conf import settings from django.core.serializers.json import DjangoJSONEncoder -from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery +from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery, Count from django.utils import timezone # Third Party imports from rest_framework import status -from rest_framework.permissions import AllowAny from rest_framework.response import Response # Module imports @@ -28,18 +29,17 @@ from plane.db.models import ( UserFavorite, DeployBoard, Intake, - IssueUserProperty, Project, ProjectIdentifier, ProjectMember, ProjectNetwork, + ProjectUserProperty, State, DEFAULT_STATES, Workspace, WorkspaceMember, ) -from plane.utils.cache import cache_response -from plane.utils.exception_logger import log_exception +from plane.db.models.intake import IntakeIssueStatus from plane.utils.host import base_host @@ -50,11 +50,10 @@ class ProjectViewSet(BaseViewSet): use_read_replica = True def get_queryset(self): - sort_order = ProjectMember.objects.filter( - member=self.request.user, + sort_order = ProjectUserProperty.objects.filter( + user=self.request.user, project_id=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), - is_active=True, ).values("sort_order") return self.filter_queryset( super() @@ -140,11 +139,10 @@ class ProjectViewSet(BaseViewSet): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def list(self, request, slug): - sort_order = ProjectMember.objects.filter( - member=self.request.user, + sort_order = ProjectUserProperty.objects.filter( + user=self.request.user, project_id=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), - is_active=True, ).values("sort_order") projects = ( @@ -157,6 +155,15 @@ class ProjectViewSet(BaseViewSet): is_active=True, ).values("role") ) + .annotate( + intake_count=Count( + "project_intakeissue", + filter=Q( + project_intakeissue__status=IntakeIssueStatus.PENDING.value, + project_intakeissue__deleted_at__isnull=True, + ), + ) + ) .annotate(inbox_view=F("intake_view")) .annotate(sort_order=Subquery(sort_order)) .distinct() @@ -167,6 +174,7 @@ class ProjectViewSet(BaseViewSet): "sort_order", "logo_props", "member_role", + "intake_count", "archived_at", "workspace", "cycle_view", @@ -255,8 +263,6 @@ class ProjectViewSet(BaseViewSet): member=request.user, role=ROLE.ADMIN.value, ) - # Also create the issue property for the user - _ = IssueUserProperty.objects.create(project_id=serializer.data["id"], user=request.user) if serializer.data["project_lead"] is not None and str(serializer.data["project_lead"]) != str( request.user.id @@ -266,11 +272,6 @@ class ProjectViewSet(BaseViewSet): member_id=serializer.data["project_lead"], role=ROLE.ADMIN.value, ) - # Also create the issue property for the user - IssueUserProperty.objects.create( - project_id=serializer.data["id"], - user_id=serializer.data["project_lead"], - ) State.objects.bulk_create( [ diff --git a/apps/api/plane/app/views/project/invite.py b/apps/api/plane/app/views/project/invite.py index cc5b3f4b5..19d8c36bc 100644 --- a/apps/api/plane/app/views/project/invite.py +++ b/apps/api/plane/app/views/project/invite.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import jwt from datetime import datetime @@ -24,7 +28,7 @@ from plane.db.models import ( User, WorkspaceMember, Project, - IssueUserProperty, + ProjectUserProperty, ) from plane.db.models.project import ProjectNetwork from plane.utils.host import base_host @@ -160,9 +164,9 @@ class UserProjectInvitationsViewset(BaseViewSet): ignore_conflicts=True, ) - IssueUserProperty.objects.bulk_create( + ProjectUserProperty.objects.bulk_create( [ - IssueUserProperty( + ProjectUserProperty( project_id=project_id, user=request.user, workspace=workspace, @@ -220,7 +224,7 @@ class ProjectJoinEndpoint(BaseAPIView): if project_member is None: # Create a Project Member _ = ProjectMember.objects.create( - workspace_id=project_invite.workspace_id, + project_id=project_id, member=user, role=project_invite.role, ) diff --git a/apps/api/plane/app/views/project/member.py b/apps/api/plane/app/views/project/member.py index 3ab7061e1..7dfe70900 100644 --- a/apps/api/plane/app/views/project/member.py +++ b/apps/api/plane/app/views/project/member.py @@ -1,6 +1,11 @@ +# 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 django.db.models import Min # Module imports from .base import BaseViewSet, BaseAPIView @@ -13,7 +18,7 @@ from plane.app.serializers import ( from plane.app.permissions import WorkspaceUserPermission -from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember +from plane.db.models import Project, ProjectMember, ProjectUserProperty, WorkspaceMember from plane.bgtasks.project_add_user_email_task import project_add_user_email from plane.utils.host import base_host from plane.app.permissions.base import allow_permission, ROLE @@ -89,24 +94,23 @@ class ProjectMemberViewSet(BaseViewSet): # Update the roles of the existing members ProjectMember.objects.bulk_update(bulk_project_members, ["is_active", "role"], batch_size=100) - # Get the list of project members of the requested workspace with the given slug - project_members = ( - ProjectMember.objects.filter( + # Get the minimum sort_order for each member in the workspace + member_sort_orders = ( + ProjectUserProperty.objects.filter( workspace__slug=slug, - member_id__in=[member.get("member_id") for member in members], + user_id__in=[member.get("member_id") for member in members], ) - .values("member_id", "sort_order") - .order_by("sort_order") + .values("user_id") + .annotate(min_sort_order=Min("sort_order")) ) + # Convert to dictionary for easy lookup: {user_id: min_sort_order} + sort_order_map = {str(item["user_id"]): item["min_sort_order"] for item in member_sort_orders} # Loop through requested members for member in members: - # Get the sort orders of the member - sort_order = [ - project_member.get("sort_order") - for project_member in project_members - if str(project_member.get("member_id")) == str(member.get("member_id")) - ] + member_id = str(member.get("member_id")) + # Get the minimum sort_order for this member, or use default + min_sort_order = sort_order_map.get(member_id) # Create a new project member bulk_project_members.append( ProjectMember( @@ -114,22 +118,22 @@ class ProjectMemberViewSet(BaseViewSet): role=member.get("role", 5), project_id=project_id, workspace_id=project.workspace_id, - sort_order=(sort_order[0] - 10000 if len(sort_order) else 65535), ) ) # Create a new issue property bulk_issue_props.append( - IssueUserProperty( + ProjectUserProperty( user_id=member.get("member_id"), project_id=project_id, workspace_id=project.workspace_id, + sort_order=(min_sort_order - 10000 if min_sort_order is not None else 65535), ) ) # Bulk create the project members and issue properties project_members = ProjectMember.objects.bulk_create(bulk_project_members, batch_size=10, ignore_conflicts=True) - _ = IssueUserProperty.objects.bulk_create(bulk_issue_props, batch_size=10, ignore_conflicts=True) + _ = ProjectUserProperty.objects.bulk_create(bulk_issue_props, batch_size=10, ignore_conflicts=True) project_members = ProjectMember.objects.filter( project_id=project_id, @@ -222,21 +226,36 @@ class ProjectMemberViewSet(BaseViewSet): is_active=True, ) - if workspace_role in [5] and int(request.data.get("role", project_member.role)) in [15, 20]: - return Response( - {"error": "You cannot add a user with role higher than the workspace role"}, - status=status.HTTP_400_BAD_REQUEST, - ) + if "role" in request.data: + # Only Admins can modify roles + if requested_project_member.role < ROLE.ADMIN.value and not is_workspace_admin: + return Response( + {"error": "You do not have permission to update roles"}, + status=status.HTTP_403_FORBIDDEN, + ) - if ( - "role" in request.data - and int(request.data.get("role", project_member.role)) > requested_project_member.role - and not is_workspace_admin - ): - return Response( - {"error": "You cannot update a role that is higher than your own role"}, - status=status.HTTP_400_BAD_REQUEST, - ) + # Cannot modify a member whose role is equal to or higher than your own + if project_member.role >= requested_project_member.role and not is_workspace_admin: + return Response( + {"error": "You cannot update the role of a member with a role equal to or higher than your own"}, + status=status.HTTP_403_FORBIDDEN, + ) + + new_role = int(request.data.get("role")) + + # Cannot assign a role equal to or higher than your own + if new_role >= requested_project_member.role and not is_workspace_admin: + return Response( + {"error": "You cannot assign a role equal to or higher than your own"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Cannot assign a role higher than the target's workspace role + if workspace_role in [5] and new_role in [15, 20]: + return Response( + {"error": "You cannot add a user with role higher than the workspace role"}, + status=status.HTTP_400_BAD_REQUEST, + ) serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True) diff --git a/apps/api/plane/app/views/search/base.py b/apps/api/plane/app/views/search/base.py index f1e692653..3bfbecaaf 100644 --- a/apps/api/plane/app/views/search/base.py +++ b/apps/api/plane/app/views/search/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import re diff --git a/apps/api/plane/app/views/search/issue.py b/apps/api/plane/app/views/search/issue.py index ac9d783a9..737fe6410 100644 --- a/apps/api/plane/app/views/search/issue.py +++ b/apps/api/plane/app/views/search/issue.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.db.models import Q, QuerySet diff --git a/apps/api/plane/app/views/state/base.py b/apps/api/plane/app/views/state/base.py index ce1597a68..55c232fdf 100644 --- a/apps/api/plane/app/views/state/base.py +++ b/apps/api/plane/app/views/state/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports from itertools import groupby from collections import defaultdict diff --git a/apps/api/plane/app/views/timezone/base.py b/apps/api/plane/app/views/timezone/base.py index fc0121179..9644ceee3 100644 --- a/apps/api/plane/app/views/timezone/base.py +++ b/apps/api/plane/app/views/timezone/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import pytz from datetime import datetime diff --git a/apps/api/plane/app/views/user/base.py b/apps/api/plane/app/views/user/base.py index 72d42010c..914dffb3b 100644 --- a/apps/api/plane/app/views/user/base.py +++ b/apps/api/plane/app/views/user/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import uuid import json diff --git a/apps/api/plane/app/views/view/base.py b/apps/api/plane/app/views/view/base.py index 98fe04c62..5ca7aac42 100644 --- a/apps/api/plane/app/views/view/base.py +++ b/apps/api/plane/app/views/view/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import copy # Django imports diff --git a/apps/api/plane/app/views/webhook/base.py b/apps/api/plane/app/views/webhook/base.py index e857c3e08..c874f0a42 100644 --- a/apps/api/plane/app/views/webhook/base.py +++ b/apps/api/plane/app/views/webhook/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.db import IntegrityError diff --git a/apps/api/plane/app/views/workspace/base.py b/apps/api/plane/app/views/workspace/base.py index c27b7adbb..be43eace2 100644 --- a/apps/api/plane/app/views/workspace/base.py +++ b/apps/api/plane/app/views/workspace/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import csv import io @@ -42,7 +46,10 @@ from plane.app.permissions import ROLE, allow_permission from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS from plane.license.utils.instance_value import get_configuration_value from plane.bgtasks.workspace_seed_task import workspace_seed +from plane.bgtasks.event_tracking_task import track_event from plane.utils.url import contains_url +from plane.utils.analytics_events import WORKSPACE_CREATED, WORKSPACE_DELETED +from plane.utils.csv_utils import sanitize_csv_row class WorkSpaceViewSet(BaseViewSet): @@ -131,6 +138,20 @@ class WorkSpaceViewSet(BaseViewSet): workspace_seed.delay(serializer.data["id"]) + track_event.delay( + user_id=request.user.id, + event_name=WORKSPACE_CREATED, + slug=data["slug"], + event_properties={ + "user_id": request.user.id, + "workspace_id": data["id"], + "workspace_slug": data["slug"], + "role": "owner", + "workspace_name": data["name"], + "created_at": data["created_at"], + }, + ) + return Response(data, status=status.HTTP_201_CREATED) return Response( [serializer.errors[error][0] for error in serializer.errors], @@ -164,6 +185,19 @@ class WorkSpaceViewSet(BaseViewSet): # Get the workspace workspace = self.get_object() self.remove_last_workspace_ids_from_user_settings(workspace.id) + track_event.delay( + user_id=request.user.id, + event_name=WORKSPACE_DELETED, + slug=workspace.slug, + event_properties={ + "user_id": request.user.id, + "workspace_id": workspace.id, + "workspace_slug": workspace.slug, + "role": "owner", + "workspace_name": workspace.name, + "deleted_at": str(timezone.now().isoformat()), + }, + ) return super().destroy(request, *args, **kwargs) @@ -338,7 +372,7 @@ class ExportWorkspaceUserActivityEndpoint(BaseAPIView): """Generate CSV buffer from rows.""" csv_buffer = io.StringIO() writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL) - [writer.writerow(row) for row in rows] + [writer.writerow(sanitize_csv_row(row)) for row in rows] csv_buffer.seek(0) return csv_buffer diff --git a/apps/api/plane/app/views/workspace/cycle.py b/apps/api/plane/app/views/workspace/cycle.py index 73deca059..deb86c5c4 100644 --- a/apps/api/plane/app/views/workspace/cycle.py +++ b/apps/api/plane/app/views/workspace/cycle.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.db.models import Q, Count diff --git a/apps/api/plane/app/views/workspace/draft.py b/apps/api/plane/app/views/workspace/draft.py index c89fe4a73..aa228ded3 100644 --- a/apps/api/plane/app/views/workspace/draft.py +++ b/apps/api/plane/app/views/workspace/draft.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json diff --git a/apps/api/plane/app/views/workspace/estimate.py b/apps/api/plane/app/views/workspace/estimate.py index 8cba3d170..7f5bb66f6 100644 --- a/apps/api/plane/app/views/workspace/estimate.py +++ b/apps/api/plane/app/views/workspace/estimate.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Third party modules from rest_framework import status from rest_framework.response import Response diff --git a/apps/api/plane/app/views/workspace/favorite.py b/apps/api/plane/app/views/workspace/favorite.py index 8a8bfed6c..8217f0fb0 100644 --- a/apps/api/plane/app/views/workspace/favorite.py +++ b/apps/api/plane/app/views/workspace/favorite.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Third party modules from rest_framework import status from rest_framework.response import Response diff --git a/apps/api/plane/app/views/workspace/home.py b/apps/api/plane/app/views/workspace/home.py index 731164eb1..ec35aaf4e 100644 --- a/apps/api/plane/app/views/workspace/home.py +++ b/apps/api/plane/app/views/workspace/home.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from ..base import BaseAPIView from plane.db.models.workspace import WorkspaceHomePreference diff --git a/apps/api/plane/app/views/workspace/invite.py b/apps/api/plane/app/views/workspace/invite.py index 48bcf7eba..cf2ab795a 100644 --- a/apps/api/plane/app/views/workspace/invite.py +++ b/apps/api/plane/app/views/workspace/invite.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports from datetime import datetime @@ -21,12 +25,12 @@ from plane.app.serializers import ( WorkSpaceMemberSerializer, ) from plane.app.views.base import BaseAPIView -from plane.bgtasks.event_tracking_task import workspace_invite_event +from plane.bgtasks.event_tracking_task import track_event from plane.bgtasks.workspace_invitation_task import workspace_invitation from plane.db.models import User, Workspace, WorkspaceMember, WorkspaceMemberInvite from plane.utils.cache import invalidate_cache, invalidate_cache_directly from plane.utils.host import base_host -from plane.utils.ip_address import get_client_ip +from plane.utils.analytics_events import USER_JOINED_WORKSPACE, USER_INVITED_TO_WORKSPACE from .. import BaseViewSet @@ -121,6 +125,19 @@ class WorkspaceInvitationsViewset(BaseViewSet): current_site, request.user.email, ) + track_event.delay( + user_id=request.user.id, + event_name=USER_INVITED_TO_WORKSPACE, + slug=slug, + event_properties={ + "user_id": request.user.id, + "workspace_id": workspace.id, + "workspace_slug": workspace.slug, + "invitee_role": invitation.role, + "invited_at": str(timezone.now()), + "invitee_email": invitation.email, + }, + ) return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK) @@ -146,10 +163,10 @@ class WorkspaceJoinEndpoint(BaseAPIView): def post(self, request, slug, pk): workspace_invite = WorkspaceMemberInvite.objects.get(pk=pk, workspace__slug=slug) - email = request.data.get("email", "") + token = request.data.get("token", "") - # Check the email - if email == "" or workspace_invite.email != email: + # Validate the token to verify the user received the invitation email + if not token or workspace_invite.token != token: return Response( {"error": "You do not have permission to join the workspace"}, status=status.HTTP_403_FORBIDDEN, @@ -163,7 +180,7 @@ class WorkspaceJoinEndpoint(BaseAPIView): if workspace_invite.accepted: # Check if the user created account after invitation - user = User.objects.filter(email=email).first() + user = User.objects.filter(email=workspace_invite.email).first() # If the user is present then create the workspace member if user is not None: @@ -186,20 +203,22 @@ class WorkspaceJoinEndpoint(BaseAPIView): # Set the user last_workspace_id to the accepted workspace user.last_workspace_id = workspace_invite.workspace.id user.save() + track_event.delay( + user_id=user.id, + event_name=USER_JOINED_WORKSPACE, + slug=slug, + event_properties={ + "user_id": user.id, + "workspace_id": workspace_invite.workspace.id, + "workspace_slug": workspace_invite.workspace.slug, + "role": workspace_invite.role, + "joined_at": str(timezone.now()), + }, + ) # Delete the invitation workspace_invite.delete() - # Send event - workspace_invite_event.delay( - user=user.id if user is not None else None, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=get_client_ip(request=request), - event_name="MEMBER_ACCEPTED", - accepted_from="EMAIL", - ) - return Response( {"message": "Workspace Invitation Accepted"}, status=status.HTTP_200_OK, @@ -252,6 +271,20 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet): is_active=True, role=invitation.role ) + # Track event + track_event.delay( + user_id=request.user.id, + event_name=USER_JOINED_WORKSPACE, + slug=invitation.workspace.slug, + event_properties={ + "user_id": request.user.id, + "workspace_id": invitation.workspace.id, + "workspace_slug": invitation.workspace.slug, + "role": invitation.role, + "joined_at": str(timezone.now()), + }, + ) + # Bulk create the user for all the workspaces WorkspaceMember.objects.bulk_create( [ diff --git a/apps/api/plane/app/views/workspace/label.py b/apps/api/plane/app/views/workspace/label.py index 11ca6b913..926a504a3 100644 --- a/apps/api/plane/app/views/workspace/label.py +++ b/apps/api/plane/app/views/workspace/label.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Third party modules from rest_framework import status from rest_framework.response import Response diff --git a/apps/api/plane/app/views/workspace/member.py b/apps/api/plane/app/views/workspace/member.py index 3394cb253..67c7637a8 100644 --- a/apps/api/plane/app/views/workspace/member.py +++ b/apps/api/plane/app/views/workspace/member.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.db.models import Count, Q, OuterRef, Subquery, IntegerField from django.utils import timezone diff --git a/apps/api/plane/app/views/workspace/module.py b/apps/api/plane/app/views/workspace/module.py index e61fc70e7..b04848140 100644 --- a/apps/api/plane/app/views/workspace/module.py +++ b/apps/api/plane/app/views/workspace/module.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.db.models import Prefetch, Q, Count @@ -42,7 +46,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): ) .annotate( completed_issues=Count( - "issue_module__issue__state__group", + "issue_module", filter=Q( issue_module__issue__state__group="completed", issue_module__issue__archived_at__isnull=True, @@ -54,7 +58,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): ) .annotate( cancelled_issues=Count( - "issue_module__issue__state__group", + "issue_module", filter=Q( issue_module__issue__state__group="cancelled", issue_module__issue__archived_at__isnull=True, @@ -66,7 +70,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): ) .annotate( started_issues=Count( - "issue_module__issue__state__group", + "issue_module", filter=Q( issue_module__issue__state__group="started", issue_module__issue__archived_at__isnull=True, @@ -78,7 +82,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): ) .annotate( unstarted_issues=Count( - "issue_module__issue__state__group", + "issue_module", filter=Q( issue_module__issue__state__group="unstarted", issue_module__issue__archived_at__isnull=True, @@ -90,7 +94,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): ) .annotate( backlog_issues=Count( - "issue_module__issue__state__group", + "issue_module", filter=Q( issue_module__issue__state__group="backlog", issue_module__issue__archived_at__isnull=True, diff --git a/apps/api/plane/app/views/workspace/quick_link.py b/apps/api/plane/app/views/workspace/quick_link.py index 82c104573..ba971c54f 100644 --- a/apps/api/plane/app/views/workspace/quick_link.py +++ b/apps/api/plane/app/views/workspace/quick_link.py @@ -1,3 +1,7 @@ +# 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 import status from rest_framework.response import Response diff --git a/apps/api/plane/app/views/workspace/recent_visit.py b/apps/api/plane/app/views/workspace/recent_visit.py index 0d9c1ef9b..a9394766a 100644 --- a/apps/api/plane/app/views/workspace/recent_visit.py +++ b/apps/api/plane/app/views/workspace/recent_visit.py @@ -1,3 +1,7 @@ +# 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 import status from rest_framework.response import Response diff --git a/apps/api/plane/app/views/workspace/state.py b/apps/api/plane/app/views/workspace/state.py index 3bfc8d22d..a8c5b368d 100644 --- a/apps/api/plane/app/views/workspace/state.py +++ b/apps/api/plane/app/views/workspace/state.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Third party modules from rest_framework import status from rest_framework.response import Response diff --git a/apps/api/plane/app/views/workspace/sticky.py b/apps/api/plane/app/views/workspace/sticky.py index 8ab6c5c98..9cf153225 100644 --- a/apps/api/plane/app/views/workspace/sticky.py +++ b/apps/api/plane/app/views/workspace/sticky.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/app/views/workspace/user.py b/apps/api/plane/app/views/workspace/user.py index b45c6e410..b60ae5e15 100644 --- a/apps/api/plane/app/views/workspace/user.py +++ b/apps/api/plane/app/views/workspace/user.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import copy from datetime import date diff --git a/apps/api/plane/app/views/workspace/user_preference.py b/apps/api/plane/app/views/workspace/user_preference.py index 253f715b3..83e380b9e 100644 --- a/apps/api/plane/app/views/workspace/user_preference.py +++ b/apps/api/plane/app/views/workspace/user_preference.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from ..base import BaseAPIView from plane.db.models.workspace import WorkspaceUserPreference diff --git a/apps/api/plane/asgi.py b/apps/api/plane/asgi.py index 2dd703ffe..9d3bd6b07 100644 --- a/apps/api/plane/asgi.py +++ b/apps/api/plane/asgi.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import os from channels.routing import ProtocolTypeRouter diff --git a/apps/api/plane/authentication/__init__.py b/apps/api/plane/authentication/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/authentication/__init__.py +++ b/apps/api/plane/authentication/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/authentication/adapter/__init__.py b/apps/api/plane/authentication/adapter/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/authentication/adapter/__init__.py +++ b/apps/api/plane/authentication/adapter/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/authentication/adapter/base.py b/apps/api/plane/authentication/adapter/base.py index b80555fe1..b80dfa500 100644 --- a/apps/api/plane/authentication/adapter/base.py +++ b/apps/api/plane/authentication/adapter/base.py @@ -1,26 +1,35 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# 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 .error import AUTHENTICATION_ERROR_CODES, AuthenticationException class Adapter: @@ -32,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 @@ -54,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", @@ -67,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", @@ -79,9 +91,10 @@ 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["INVALID_PASSWORD"], - error_message="INVALID_PASSWORD", + error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"], + error_message="PASSWORD_TOO_WEAK", payload={"email": email}, ) return @@ -96,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"], @@ -108,6 +122,20 @@ class Adapter: def get_avatar_download_headers(self): return {} + def check_sync_enabled(self): + """Check if sync is enabled for the provider""" + provider_config_map = { + "google": "ENABLE_GOOGLE_SYNC", + "github": "ENABLE_GITHUB_SYNC", + "gitlab": "ENABLE_GITLAB_SYNC", + "gitea": "ENABLE_GITEA_SYNC", + } + config_key = provider_config_map.get(self.provider) + if config_key: + (enabled,) = get_configuration_value([{"key": config_key, "default": os.environ.get(config_key, "0")}]) + return enabled == "1" + return False + def download_and_upload_avatar(self, avatar_url, user): """ Downloads avatar from OAuth provider and uploads to our storage. @@ -156,9 +184,6 @@ class Adapter: # Generate unique filename filename = f"{uuid.uuid4().hex}-user-avatar.{extension}" - # Upload to S3/MinIO storage - from plane.settings.storage import S3Storage - storage = S3Storage(request=self.request) # Create file-like object @@ -208,6 +233,59 @@ class Adapter: user.save() return user + def delete_old_avatar(self, user): + """Delete the old avatar if it exists""" + try: + if user.avatar_asset: + asset = FileAsset.objects.get(pk=user.avatar_asset_id) + storage = S3Storage(request=self.request) + storage.delete_files(object_names=[asset.asset.name]) + + # Delete the user avatar + asset.delete() + user.avatar_asset = None + user.avatar = "" + user.save() + return + except FileAsset.DoesNotExist: + pass + except Exception as e: + log_exception(e) + return + + def sync_user_data(self, user): + # Update user details + first_name = self.user_data.get("user", {}).get("first_name", "") + last_name = self.user_data.get("user", {}).get("last_name", "") + user.first_name = first_name if first_name else "" + user.last_name = last_name if last_name else "" + + # Get email + email = self.user_data.get("email") + + # Get display name + display_name = self.user_data.get("user", {}).get("display_name") + # If display name is not provided, generate a random display name + if not display_name: + display_name = User.get_display_name(email) + + # Set display name + user.display_name = display_name + + # Download and upload avatar only if the avatar is different from the one in the storage + avatar = self.user_data.get("user", {}).get("avatar", "") + # Delete the old avatar if it exists + self.delete_old_avatar(user=user) + avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user) + if avatar_asset: + user.avatar_asset = avatar_asset + # If avatar upload fails, set the avatar to the original URL + else: + user.avatar = avatar + + user.save() + return user + def complete_login_or_signup(self): # Get email email = self.user_data.get("email") @@ -255,6 +333,7 @@ class Adapter: avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user) if avatar_asset: user.avatar_asset = avatar_asset + user.avatar = avatar # If avatar upload fails, set the avatar to the original URL else: user.avatar = avatar @@ -262,6 +341,10 @@ class Adapter: # Create profile Profile.objects.create(user=user) + # Check if IDP sync is enabled and user is not signing up + if self.check_sync_enabled() and not is_signup: + user = self.sync_user_data(user=user) + # Save user data user = self.save_user_data(user=user) diff --git a/apps/api/plane/authentication/adapter/credential.py b/apps/api/plane/authentication/adapter/credential.py index 0327289ca..eee6ea97f 100644 --- a/apps/api/plane/authentication/adapter/credential.py +++ b/apps/api/plane/authentication/adapter/credential.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from plane.authentication.adapter.base import Adapter diff --git a/apps/api/plane/authentication/adapter/error.py b/apps/api/plane/authentication/adapter/error.py index 25a7cf567..f91565df2 100644 --- a/apps/api/plane/authentication/adapter/error.py +++ b/apps/api/plane/authentication/adapter/error.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + AUTHENTICATION_ERROR_CODES = { # Global "INSTANCE_NOT_CONFIGURED": 5000, @@ -9,6 +13,7 @@ AUTHENTICATION_ERROR_CODES = { "USER_ACCOUNT_DEACTIVATED": 5019, # Password strength "INVALID_PASSWORD": 5020, + "PASSWORD_TOO_WEAK": 5021, "SMTP_NOT_CONFIGURED": 5025, # Sign Up "USER_ALREADY_EXIST": 5030, diff --git a/apps/api/plane/authentication/adapter/exception.py b/apps/api/plane/authentication/adapter/exception.py index e906c5a50..c8d28762a 100644 --- a/apps/api/plane/authentication/adapter/exception.py +++ b/apps/api/plane/authentication/adapter/exception.py @@ -1,3 +1,7 @@ +# 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.views import exception_handler from rest_framework.exceptions import NotAuthenticated diff --git a/apps/api/plane/authentication/adapter/oauth.py b/apps/api/plane/authentication/adapter/oauth.py index d8e423d0e..0bef76b24 100644 --- a/apps/api/plane/authentication/adapter/oauth.py +++ b/apps/api/plane/authentication/adapter/oauth.py @@ -1,19 +1,24 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # 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): @@ -74,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)) @@ -84,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)) diff --git a/apps/api/plane/authentication/apps.py b/apps/api/plane/authentication/apps.py index cf5cdca1c..5a612eaa9 100644 --- a/apps/api/plane/authentication/apps.py +++ b/apps/api/plane/authentication/apps.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.apps import AppConfig diff --git a/apps/api/plane/authentication/middleware/__init__.py b/apps/api/plane/authentication/middleware/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/authentication/middleware/__init__.py +++ b/apps/api/plane/authentication/middleware/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/authentication/middleware/session.py b/apps/api/plane/authentication/middleware/session.py index c367a15d3..817f898fd 100644 --- a/apps/api/plane/authentication/middleware/session.py +++ b/apps/api/plane/authentication/middleware/session.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import time from importlib import import_module diff --git a/apps/api/plane/authentication/provider/__init__.py b/apps/api/plane/authentication/provider/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/authentication/provider/__init__.py +++ b/apps/api/plane/authentication/provider/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/authentication/provider/credentials/__init__.py b/apps/api/plane/authentication/provider/credentials/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/authentication/provider/credentials/__init__.py +++ b/apps/api/plane/authentication/provider/credentials/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/authentication/provider/credentials/email.py b/apps/api/plane/authentication/provider/credentials/email.py index c3d19a80e..e2c424588 100644 --- a/apps/api/plane/authentication/provider/credentials/email.py +++ b/apps/api/plane/authentication/provider/credentials/email.py @@ -1,13 +1,17 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports 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 @@ -20,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( @@ -39,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"], @@ -70,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" @@ -80,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 diff --git a/apps/api/plane/authentication/provider/credentials/magic_code.py b/apps/api/plane/authentication/provider/credentials/magic_code.py index e7c5cfff9..a6c9883d6 100644 --- a/apps/api/plane/authentication/provider/credentials/magic_code.py +++ b/apps/api/plane/authentication/provider/credentials/magic_code.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json import os diff --git a/apps/api/plane/authentication/provider/oauth/__init__.py b/apps/api/plane/authentication/provider/oauth/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/authentication/provider/oauth/__init__.py +++ b/apps/api/plane/authentication/provider/oauth/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/authentication/provider/oauth/gitea.py b/apps/api/plane/authentication/provider/oauth/gitea.py index ba7d3d16b..8c0c3a5db 100644 --- a/apps/api/plane/authentication/provider/oauth/gitea.py +++ b/apps/api/plane/authentication/provider/oauth/gitea.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import os from datetime import datetime, timedelta from urllib.parse import urlencode, urlparse @@ -101,9 +105,7 @@ class GiteaOAuthProvider(OauthAdapter): else None ), "refresh_token_expired_at": ( - datetime.fromtimestamp( - token_response.get("refresh_token_expired_at"), tz=pytz.utc - ) + datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc) if token_response.get("refresh_token_expired_at") else None ), @@ -168,4 +170,4 @@ class GiteaOAuthProvider(OauthAdapter): "is_password_autoset": True, }, } - ) \ No newline at end of file + ) diff --git a/apps/api/plane/authentication/provider/oauth/github.py b/apps/api/plane/authentication/provider/oauth/github.py index 54c48018e..363cd722e 100644 --- a/apps/api/plane/authentication/provider/oauth/github.py +++ b/apps/api/plane/authentication/provider/oauth/github.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os from datetime import datetime @@ -6,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): @@ -26,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( @@ -86,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", @@ -134,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, + }, + }) diff --git a/apps/api/plane/authentication/provider/oauth/gitlab.py b/apps/api/plane/authentication/provider/oauth/gitlab.py index de4a3515e..088987c23 100644 --- a/apps/api/plane/authentication/provider/oauth/gitlab.py +++ b/apps/api/plane/authentication/provider/oauth/gitlab.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os from datetime import datetime diff --git a/apps/api/plane/authentication/provider/oauth/google.py b/apps/api/plane/authentication/provider/oauth/google.py index 41293782f..b02eda87d 100644 --- a/apps/api/plane/authentication/provider/oauth/google.py +++ b/apps/api/plane/authentication/provider/oauth/google.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os from datetime import datetime diff --git a/apps/api/plane/authentication/rate_limit.py b/apps/api/plane/authentication/rate_limit.py index d245d50b3..f939ef25c 100644 --- a/apps/api/plane/authentication/rate_limit.py +++ b/apps/api/plane/authentication/rate_limit.py @@ -1,3 +1,7 @@ +# 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.throttling import AnonRateThrottle, UserRateThrottle from rest_framework import status diff --git a/apps/api/plane/authentication/session.py b/apps/api/plane/authentication/session.py index 862a63c13..fe2aa0c35 100644 --- a/apps/api/plane/authentication/session.py +++ b/apps/api/plane/authentication/session.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from rest_framework.authentication import SessionAuthentication diff --git a/apps/api/plane/authentication/urls.py b/apps/api/plane/authentication/urls.py index 64b8e654c..4bec07db0 100644 --- a/apps/api/plane/authentication/urls.py +++ b/apps/api/plane/authentication/urls.py @@ -1,3 +1,7 @@ +# 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 .views import ( diff --git a/apps/api/plane/authentication/utils/host.py b/apps/api/plane/authentication/utils/host.py index 415791a87..d79d54e8a 100644 --- a/apps/api/plane/authentication/utils/host.py +++ b/apps/api/plane/authentication/utils/host.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.conf import settings from django.http import HttpRequest diff --git a/apps/api/plane/authentication/utils/login.py b/apps/api/plane/authentication/utils/login.py index fe6fdad93..d57333551 100644 --- a/apps/api/plane/authentication/utils/login.py +++ b/apps/api/plane/authentication/utils/login.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.contrib.auth import login from django.conf import settings diff --git a/apps/api/plane/authentication/utils/redirection_path.py b/apps/api/plane/authentication/utils/redirection_path.py index 82139b821..59d4b7d50 100644 --- a/apps/api/plane/authentication/utils/redirection_path.py +++ b/apps/api/plane/authentication/utils/redirection_path.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from plane.db.models import Profile, Workspace, WorkspaceMemberInvite diff --git a/apps/api/plane/authentication/utils/user_auth_workflow.py b/apps/api/plane/authentication/utils/user_auth_workflow.py index 13de4c287..4641f332c 100644 --- a/apps/api/plane/authentication/utils/user_auth_workflow.py +++ b/apps/api/plane/authentication/utils/user_auth_workflow.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .workspace_project_join import process_workspace_project_invitations diff --git a/apps/api/plane/authentication/utils/workspace_project_join.py b/apps/api/plane/authentication/utils/workspace_project_join.py index bd5ad8501..9222791a8 100644 --- a/apps/api/plane/authentication/utils/workspace_project_join.py +++ b/apps/api/plane/authentication/utils/workspace_project_join.py @@ -1,3 +1,11 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Django imports +from django.utils import timezone + +# Module imports from plane.db.models import ( ProjectMember, ProjectMemberInvite, @@ -5,6 +13,8 @@ from plane.db.models import ( WorkspaceMemberInvite, ) from plane.utils.cache import invalidate_cache_directly +from plane.bgtasks.event_tracking_task import track_event +from plane.utils.analytics_events import USER_JOINED_WORKSPACE def process_workspace_project_invitations(user): @@ -25,15 +35,25 @@ def process_workspace_project_invitations(user): ignore_conflicts=True, ) - [ + for workspace_member_invite in workspace_member_invites: invalidate_cache_directly( path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/", url_params=False, user=False, multiple=True, ) - for workspace_member_invite in workspace_member_invites - ] + track_event.delay( + user_id=user.id, + event_name=USER_JOINED_WORKSPACE, + slug=workspace_member_invite.workspace.slug, + event_properties={ + "user_id": user.id, + "workspace_id": workspace_member_invite.workspace.id, + "workspace_slug": workspace_member_invite.workspace.slug, + "role": workspace_member_invite.role, + "joined_at": str(timezone.now().isoformat()), + }, + ) # Check if user has any project invites project_member_invites = ProjectMemberInvite.objects.filter(email=user.email, accepted=True) diff --git a/apps/api/plane/authentication/views/__init__.py b/apps/api/plane/authentication/views/__init__.py index 2595d2e75..a9c816ae9 100644 --- a/apps/api/plane/authentication/views/__init__.py +++ b/apps/api/plane/authentication/views/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .common import ChangePasswordEndpoint, CSRFTokenEndpoint, SetUserPasswordEndpoint from .app.check import EmailCheckEndpoint diff --git a/apps/api/plane/authentication/views/app/check.py b/apps/api/plane/authentication/views/app/check.py index 10457b45a..97ab24def 100644 --- a/apps/api/plane/authentication/views/app/check.py +++ b/apps/api/plane/authentication/views/app/check.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os diff --git a/apps/api/plane/authentication/views/app/email.py b/apps/api/plane/authentication/views/app/email.py index 864ff102b..3d1954875 100644 --- a/apps/api/plane/authentication/views/app/email.py +++ b/apps/api/plane/authentication/views/app/email.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.core.exceptions import ValidationError from django.core.validators import validate_email diff --git a/apps/api/plane/authentication/views/app/gitea.py b/apps/api/plane/authentication/views/app/gitea.py index fd12f8b33..67d25e1ab 100644 --- a/apps/api/plane/authentication/views/app/gitea.py +++ b/apps/api/plane/authentication/views/app/gitea.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import uuid from urllib.parse import urlencode, urljoin @@ -37,9 +41,7 @@ class GiteaOauthInitiateEndpoint(View): params = exc.get_error_dict() if next_path: params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) - ) + url = urljoin(base_host(request=request, is_app=True), "?" + urlencode(params)) return HttpResponseRedirect(url) try: state = uuid.uuid4().hex @@ -51,9 +53,7 @@ class GiteaOauthInitiateEndpoint(View): params = e.get_error_dict() if next_path: params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) - ) + url = urljoin(base_host(request=request, is_app=True), "?" + urlencode(params)) return HttpResponseRedirect(url) @@ -87,9 +87,7 @@ class GiteaCallbackEndpoint(View): return HttpResponseRedirect(url) try: - provider = GiteaOAuthProvider( - request=request, code=code, callback=post_user_auth_workflow - ) + provider = GiteaOAuthProvider(request=request, code=code, callback=post_user_auth_workflow) user = provider.authenticate() # Login the user and record his device info user_login(request=request, user=user, is_app=True) diff --git a/apps/api/plane/authentication/views/app/github.py b/apps/api/plane/authentication/views/app/github.py index 4720fc7da..82d5f4a05 100644 --- a/apps/api/plane/authentication/views/app/github.py +++ b/apps/api/plane/authentication/views/app/github.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import uuid diff --git a/apps/api/plane/authentication/views/app/gitlab.py b/apps/api/plane/authentication/views/app/gitlab.py index 665af00c1..5b0435250 100644 --- a/apps/api/plane/authentication/views/app/gitlab.py +++ b/apps/api/plane/authentication/views/app/gitlab.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import uuid diff --git a/apps/api/plane/authentication/views/app/google.py b/apps/api/plane/authentication/views/app/google.py index 0ee81c768..3dad1385a 100644 --- a/apps/api/plane/authentication/views/app/google.py +++ b/apps/api/plane/authentication/views/app/google.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import uuid diff --git a/apps/api/plane/authentication/views/app/magic.py b/apps/api/plane/authentication/views/app/magic.py index 518a5cdea..9104311a6 100644 --- a/apps/api/plane/authentication/views/app/magic.py +++ b/apps/api/plane/authentication/views/app/magic.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.core.validators import validate_email from django.http import HttpResponseRedirect diff --git a/apps/api/plane/authentication/views/app/password_management.py b/apps/api/plane/authentication/views/app/password_management.py index de0baa71b..48b54dccc 100644 --- a/apps/api/plane/authentication/views/app/password_management.py +++ b/apps/api/plane/authentication/views/app/password_management.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os from urllib.parse import urlencode, urljoin @@ -141,8 +145,8 @@ class ResetPasswordEndpoint(View): results = zxcvbn(password) if results["score"] < 3: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], - error_message="INVALID_PASSWORD", + error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"], + error_message="PASSWORD_TOO_WEAK", ) url = urljoin( base_host(request=request, is_app=True), diff --git a/apps/api/plane/authentication/views/app/signout.py b/apps/api/plane/authentication/views/app/signout.py index b8019dac1..9941da3c9 100644 --- a/apps/api/plane/authentication/views/app/signout.py +++ b/apps/api/plane/authentication/views/app/signout.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.views import View from django.contrib.auth import logout diff --git a/apps/api/plane/authentication/views/common.py b/apps/api/plane/authentication/views/common.py index c5dd1714c..086d6b0d3 100644 --- a/apps/api/plane/authentication/views/common.py +++ b/apps/api/plane/authentication/views/common.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.shortcuts import render @@ -79,8 +83,8 @@ class ChangePasswordEndpoint(APIView): results = zxcvbn(new_password) if results["score"] < 3: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_NEW_PASSWORD"], - error_message="INVALID_NEW_PASSWORD", + error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"], + error_message="PASSWORD_TOO_WEAK", ) return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) diff --git a/apps/api/plane/authentication/views/space/check.py b/apps/api/plane/authentication/views/space/check.py index 95a5e68df..371fadf36 100644 --- a/apps/api/plane/authentication/views/space/check.py +++ b/apps/api/plane/authentication/views/space/check.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os diff --git a/apps/api/plane/authentication/views/space/email.py b/apps/api/plane/authentication/views/space/email.py index 3d092591a..827348cef 100644 --- a/apps/api/plane/authentication/views/space/email.py +++ b/apps/api/plane/authentication/views/space/email.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.core.exceptions import ValidationError from django.core.validators import validate_email diff --git a/apps/api/plane/authentication/views/space/gitea.py b/apps/api/plane/authentication/views/space/gitea.py index 497a1ecc0..04c21678f 100644 --- a/apps/api/plane/authentication/views/space/gitea.py +++ b/apps/api/plane/authentication/views/space/gitea.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import uuid from urllib.parse import urlencode diff --git a/apps/api/plane/authentication/views/space/github.py b/apps/api/plane/authentication/views/space/github.py index f12498d3b..1df6a8c61 100644 --- a/apps/api/plane/authentication/views/space/github.py +++ b/apps/api/plane/authentication/views/space/github.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import uuid diff --git a/apps/api/plane/authentication/views/space/gitlab.py b/apps/api/plane/authentication/views/space/gitlab.py index 498916b34..19c057a06 100644 --- a/apps/api/plane/authentication/views/space/gitlab.py +++ b/apps/api/plane/authentication/views/space/gitlab.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import uuid diff --git a/apps/api/plane/authentication/views/space/google.py b/apps/api/plane/authentication/views/space/google.py index 0f02c1f93..daa1b48a6 100644 --- a/apps/api/plane/authentication/views/space/google.py +++ b/apps/api/plane/authentication/views/space/google.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import uuid diff --git a/apps/api/plane/authentication/views/space/magic.py b/apps/api/plane/authentication/views/space/magic.py index df940b327..37683d9ac 100644 --- a/apps/api/plane/authentication/views/space/magic.py +++ b/apps/api/plane/authentication/views/space/magic.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.core.validators import validate_email from django.http import HttpResponseRedirect diff --git a/apps/api/plane/authentication/views/space/password_management.py b/apps/api/plane/authentication/views/space/password_management.py index 12cc88f63..ed6682d74 100644 --- a/apps/api/plane/authentication/views/space/password_management.py +++ b/apps/api/plane/authentication/views/space/password_management.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os from urllib.parse import urlencode @@ -135,8 +139,8 @@ class ResetPasswordSpaceEndpoint(View): results = zxcvbn(password) if results["score"] < 3: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], - error_message="INVALID_PASSWORD", + error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"], + error_message="PASSWORD_TOO_WEAK", ) url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501 return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/signout.py b/apps/api/plane/authentication/views/space/signout.py index aa890f978..164c6409b 100644 --- a/apps/api/plane/authentication/views/space/signout.py +++ b/apps/api/plane/authentication/views/space/signout.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.views import View from django.contrib.auth import logout diff --git a/apps/api/plane/bgtasks/__init__.py b/apps/api/plane/bgtasks/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/bgtasks/__init__.py +++ b/apps/api/plane/bgtasks/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/bgtasks/analytic_plot_export.py b/apps/api/plane/bgtasks/analytic_plot_export.py index 845fb50dd..4b0983138 100644 --- a/apps/api/plane/bgtasks/analytic_plot_export.py +++ b/apps/api/plane/bgtasks/analytic_plot_export.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import csv import io @@ -9,7 +13,6 @@ from celery import shared_task # Django imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags from django.db.models import Q, Case, Value, When from django.db import models from django.db.models.functions import Concat @@ -18,8 +21,10 @@ from django.db.models.functions import Concat from plane.db.models import Issue from plane.license.utils.instance_value import get_email_configuration from plane.utils.analytics_plot import build_graph_plot +from plane.utils.email import generate_plain_text_from_html from plane.utils.exception_logger import log_exception from plane.utils.issue_filters import issue_filters +from plane.utils.csv_utils import sanitize_csv_row row_mapping = { "state__name": "State", @@ -48,7 +53,7 @@ def send_export_email(email, slug, csv_buffer, rows): """Helper function to send export email.""" subject = "Your Export is ready" html_content = render_to_string("emails/exports/analytics.html", {}) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) csv_buffer.seek(0) @@ -176,7 +181,7 @@ def generate_csv_from_rows(rows): """Generate CSV buffer from rows.""" csv_buffer = io.StringIO() writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL) - [writer.writerow(row) for row in rows] + [writer.writerow(sanitize_csv_row(row)) for row in rows] return csv_buffer diff --git a/apps/api/plane/bgtasks/apps.py b/apps/api/plane/bgtasks/apps.py index 7f6ca38f0..e5fb0aa54 100644 --- a/apps/api/plane/bgtasks/apps.py +++ b/apps/api/plane/bgtasks/apps.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.apps import AppConfig diff --git a/apps/api/plane/bgtasks/cleanup_task.py b/apps/api/plane/bgtasks/cleanup_task.py index 6b23f2571..407a67ca6 100644 --- a/apps/api/plane/bgtasks/cleanup_task.py +++ b/apps/api/plane/bgtasks/cleanup_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports from datetime import timedelta import logging diff --git a/apps/api/plane/bgtasks/copy_s3_object.py b/apps/api/plane/bgtasks/copy_s3_object.py index e7ef09e35..742966a6f 100644 --- a/apps/api/plane/bgtasks/copy_s3_object.py +++ b/apps/api/plane/bgtasks/copy_s3_object.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import uuid import base64 @@ -141,7 +145,7 @@ def copy_s3_objects_of_description_and_assets(entity_name, entity_identifier, pr external_data = sync_with_external_service(entity_name, updated_html) if external_data: - entity.description = external_data.get("description") + entity.description_json = external_data.get("description_json") entity.description_binary = base64.b64decode(external_data.get("description_binary")) entity.save() diff --git a/apps/api/plane/bgtasks/deletion_task.py b/apps/api/plane/bgtasks/deletion_task.py index 932a1fce0..11d904160 100644 --- a/apps/api/plane/bgtasks/deletion_task.py +++ b/apps/api/plane/bgtasks/deletion_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.utils import timezone from django.apps import apps diff --git a/apps/api/plane/bgtasks/dummy_data_task.py b/apps/api/plane/bgtasks/dummy_data_task.py index 390bc160b..6740495d8 100644 --- a/apps/api/plane/bgtasks/dummy_data_task.py +++ b/apps/api/plane/bgtasks/dummy_data_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import uuid import random diff --git a/apps/api/plane/bgtasks/email_notification_task.py b/apps/api/plane/bgtasks/email_notification_task.py index 1402adc41..5cf1d52af 100644 --- a/apps/api/plane/bgtasks/email_notification_task.py +++ b/apps/api/plane/bgtasks/email_notification_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import logging import re from datetime import datetime @@ -11,12 +15,12 @@ from django.template.loader import render_to_string # Django imports from django.utils import timezone -from django.utils.html import strip_tags # Module imports from plane.db.models import EmailNotificationLog, Issue, User from plane.license.utils.instance_value import get_email_configuration from plane.settings.redis import redis_instance +from plane.utils.email import generate_plain_text_from_html from plane.utils.exception_logger import log_exception @@ -256,7 +260,7 @@ def send_email_notification(issue_id, notification_data, receiver_id, email_noti "entity_type": "issue", } html_content = render_to_string("emails/notifications/issue-updates.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) try: connection = get_connection( diff --git a/apps/api/plane/bgtasks/event_tracking_task.py b/apps/api/plane/bgtasks/event_tracking_task.py index 0629db93a..e8f453e9f 100644 --- a/apps/api/plane/bgtasks/event_tracking_task.py +++ b/apps/api/plane/bgtasks/event_tracking_task.py @@ -1,5 +1,11 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import logging import os import uuid +from typing import Dict, Any # third party imports from celery import shared_task @@ -8,6 +14,11 @@ from posthog import Posthog # module imports from plane.license.utils.instance_value import get_configuration_value from plane.utils.exception_logger import log_exception +from plane.db.models import Workspace +from plane.utils.analytics_events import USER_INVITED_TO_WORKSPACE, WORKSPACE_DELETED + + +logger = logging.getLogger("plane.worker") def posthogConfiguration(): @@ -17,7 +28,10 @@ def posthogConfiguration(): "key": "POSTHOG_API_KEY", "default": os.environ.get("POSTHOG_API_KEY", None), }, - {"key": "POSTHOG_HOST", "default": os.environ.get("POSTHOG_HOST", None)}, + { + "key": "POSTHOG_HOST", + "default": os.environ.get("POSTHOG_HOST", None), + }, ] ) if POSTHOG_API_KEY and POSTHOG_HOST: @@ -26,46 +40,42 @@ def posthogConfiguration(): return None, None -@shared_task -def auth_events(user, email, user_agent, ip, event_name, medium, first_time): - try: - POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() +def preprocess_data_properties( + user_id: uuid.UUID, event_name: str, slug: str, data_properties: Dict[str, Any] +) -> Dict[str, Any]: + if event_name == USER_INVITED_TO_WORKSPACE or event_name == WORKSPACE_DELETED: + try: + # Check if the current user is the workspace owner + workspace = Workspace.objects.get(slug=slug) + if str(workspace.owner_id) == str(user_id): + data_properties["role"] = "owner" + else: + data_properties["role"] = "admin" + except Workspace.DoesNotExist: + logger.warning(f"Workspace {slug} does not exist while sending event {event_name} for user {user_id}") + data_properties["role"] = "unknown" - if POSTHOG_API_KEY and POSTHOG_HOST: - posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) - posthog.capture( - email, - event=event_name, - properties={ - "event_id": uuid.uuid4().hex, - "user": {"email": email, "id": str(user)}, - "device_ctx": {"ip": ip, "user_agent": user_agent}, - "medium": medium, - "first_time": first_time, - }, - ) - except Exception as e: - log_exception(e) - return + return data_properties @shared_task -def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from): - try: - POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() +def track_event(user_id: uuid.UUID, event_name: str, slug: str, event_properties: Dict[str, Any]): + POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() - if POSTHOG_API_KEY and POSTHOG_HOST: - posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) - posthog.capture( - email, - event=event_name, - properties={ - "event_id": uuid.uuid4().hex, - "user": {"email": email, "id": str(user)}, - "device_ctx": {"ip": ip, "user_agent": user_agent}, - "accepted_from": accepted_from, - }, - ) + if not (POSTHOG_API_KEY and POSTHOG_HOST): + logger.warning("Event tracking is not configured") + return + + try: + # preprocess the data properties for massaging the payload + # in the correct format for posthog + data_properties = preprocess_data_properties(user_id, event_name, slug, event_properties) + groups = { + "workspace": slug, + } + # track the event using posthog + posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) + posthog.capture(distinct_id=str(user_id), event=event_name, properties=data_properties, groups=groups) except Exception as e: log_exception(e) - return + return False diff --git a/apps/api/plane/bgtasks/export_task.py b/apps/api/plane/bgtasks/export_task.py index 75b5f2265..24486999d 100644 --- a/apps/api/plane/bgtasks/export_task.py +++ b/apps/api/plane/bgtasks/export_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import io import zipfile @@ -15,9 +19,10 @@ from django.utils import timezone from django.db.models import Prefetch # Module imports -from plane.db.models import ExporterHistory, Issue, IssueRelation +from plane.db.models import ExporterHistory, Issue, IssueComment, IssueRelation, IssueSubscriber from plane.utils.exception_logger import log_exception -from plane.utils.exporters import Exporter, IssueExportSchema +from plane.utils.porters.exporter import DataExporter +from plane.utils.porters.serializers.issue import IssueExportSerializer def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO: @@ -159,10 +164,16 @@ def issue_export_task( "labels", "issue_cycle__cycle", "issue_module__module", - "issue_comments", "assignees", - "issue_subscribers", "issue_link", + Prefetch( + "issue_subscribers", + queryset=IssueSubscriber.objects.select_related("subscriber"), + ), + Prefetch( + "issue_comments", + queryset=IssueComment.objects.select_related("actor").order_by("created_at"), + ), Prefetch( "issue_relation", queryset=IssueRelation.objects.select_related("related_issue", "related_issue__project"), @@ -180,11 +191,7 @@ def issue_export_task( # Create exporter for the specified format try: - exporter = Exporter( - format_type=provider, - schema_class=IssueExportSchema, - options={"list_joiner": ", "}, - ) + exporter = DataExporter(IssueExportSerializer, format_type=provider) except ValueError as e: # Invalid format type exporter_instance = ExporterHistory.objects.get(token=token_id) diff --git a/apps/api/plane/bgtasks/exporter_expired_task.py b/apps/api/plane/bgtasks/exporter_expired_task.py index 30b638c84..9ec2a0102 100644 --- a/apps/api/plane/bgtasks/exporter_expired_task.py +++ b/apps/api/plane/bgtasks/exporter_expired_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import boto3 from datetime import timedelta diff --git a/apps/api/plane/bgtasks/file_asset_task.py b/apps/api/plane/bgtasks/file_asset_task.py index d6eccf735..e54a754c9 100644 --- a/apps/api/plane/bgtasks/file_asset_task.py +++ b/apps/api/plane/bgtasks/file_asset_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os from datetime import timedelta diff --git a/apps/api/plane/bgtasks/forgot_password_task.py b/apps/api/plane/bgtasks/forgot_password_task.py index ffaba9937..9ca0548de 100644 --- a/apps/api/plane/bgtasks/forgot_password_task.py +++ b/apps/api/plane/bgtasks/forgot_password_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import logging @@ -8,10 +12,10 @@ from celery import shared_task # Third party imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags # Module imports 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 @@ -41,7 +45,7 @@ def forgot_password(first_name, email, uidb64, token, current_site): html_content = render_to_string("emails/auth/forgot_password.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) connection = get_connection( host=EMAIL_HOST, diff --git a/apps/api/plane/bgtasks/issue_activities_task.py b/apps/api/plane/bgtasks/issue_activities_task.py index a886305fd..032feb02a 100644 --- a/apps/api/plane/bgtasks/issue_activities_task.py +++ b/apps/api/plane/bgtasks/issue_activities_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json diff --git a/apps/api/plane/bgtasks/issue_automation_task.py b/apps/api/plane/bgtasks/issue_automation_task.py index 1cc303b57..83a2f72d1 100644 --- a/apps/api/plane/bgtasks/issue_automation_task.py +++ b/apps/api/plane/bgtasks/issue_automation_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json from datetime import timedelta diff --git a/apps/api/plane/bgtasks/issue_description_version_sync.py b/apps/api/plane/bgtasks/issue_description_version_sync.py index d10ebfcba..795d5e7ef 100644 --- a/apps/api/plane/bgtasks/issue_description_version_sync.py +++ b/apps/api/plane/bgtasks/issue_description_version_sync.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports from typing import Optional import logging @@ -59,7 +63,7 @@ def sync_issue_description_version(batch_size=5000, offset=0, countdown=300): "description_binary", "description_html", "description_stripped", - "description", + "description_json", )[offset:end_offset] ) @@ -92,7 +96,7 @@ def sync_issue_description_version(batch_size=5000, offset=0, countdown=300): description_binary=issue.description_binary, description_html=issue.description_html, description_stripped=issue.description_stripped, - description_json=issue.description, + description_json=issue.description_json, ) ) diff --git a/apps/api/plane/bgtasks/issue_description_version_task.py b/apps/api/plane/bgtasks/issue_description_version_task.py index 06d15705a..49689e815 100644 --- a/apps/api/plane/bgtasks/issue_description_version_task.py +++ b/apps/api/plane/bgtasks/issue_description_version_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from celery import shared_task from django.db import transaction from django.utils import timezone @@ -19,7 +23,7 @@ def should_update_existing_version( def update_existing_version(version: IssueDescriptionVersion, issue) -> None: - version.description_json = issue.description + version.description_json = issue.description_json version.description_html = issue.description_html version.description_binary = issue.description_binary version.description_stripped = issue.description_stripped diff --git a/apps/api/plane/bgtasks/issue_version_sync.py b/apps/api/plane/bgtasks/issue_version_sync.py index 761c26bc2..221a5a417 100644 --- a/apps/api/plane/bgtasks/issue_version_sync.py +++ b/apps/api/plane/bgtasks/issue_version_sync.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json from typing import Optional, List, Dict diff --git a/apps/api/plane/bgtasks/logger_task.py b/apps/api/plane/bgtasks/logger_task.py new file mode 100644 index 000000000..4a74e54bc --- /dev/null +++ b/apps/api/plane/bgtasks/logger_task.py @@ -0,0 +1,100 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Python imports +import logging +from typing import Optional, Dict, Any + +# Third party imports +from pymongo.collection import Collection +from celery import shared_task + +# Django imports +from plane.settings.mongo import MongoConnection +from plane.utils.exception_logger import log_exception +from plane.db.models import APIActivityLog + + +logger = logging.getLogger("plane.worker") + + +def get_mongo_collection() -> Optional[Collection]: + """ + Returns the MongoDB collection for external API activity logs. + """ + if not MongoConnection.is_configured(): + logger.info("MongoDB not configured") + return None + + try: + return MongoConnection.get_collection("api_activity_logs") + except Exception as e: + logger.error(f"Error getting MongoDB collection: {str(e)}") + log_exception(e) + return None + + +def safe_decode_body(content: bytes) -> Optional[str]: + """ + Safely decodes request/response body content, handling binary data. + Returns "[Binary Content]" if the content is binary, or a string representation of the content. + Returns None if the content is None or empty. + """ + # If the content is None, return None + if content is None: + return None + + # If the content is an empty bytes object, return None + if content == b"": + return None + + # Check if content is binary by looking for common binary file signatures + if content.startswith(b"\x89PNG") or content.startswith(b"\xff\xd8\xff") or content.startswith(b"%PDF"): + return "[Binary Content]" + + try: + return content.decode("utf-8") + except UnicodeDecodeError: + return "[Could not decode content]" + + +def log_to_mongo(log_document: Dict[str, Any]) -> bool: + """ + Logs the request to MongoDB if available. + """ + mongo_collection = get_mongo_collection() + if mongo_collection is None: + logger.error("MongoDB not configured") + return False + + try: + mongo_collection.insert_one(log_document) + return True + except Exception as e: + log_exception(e) + return False + + +def log_to_postgres(log_data: Dict[str, Any]) -> bool: + """ + Fallback to logging to PostgreSQL if MongoDB is unavailable. + """ + try: + APIActivityLog.objects.create(**log_data) + return True + except Exception as e: + log_exception(e) + return False + + +@shared_task +def process_logs(log_data: Dict[str, Any], mongo_log: Dict[str, Any]) -> None: + """ + Process logs to save to MongoDB or Postgres based on the configuration + """ + + if MongoConnection.is_configured(): + log_to_mongo(mongo_log) + else: + log_to_postgres(log_data) diff --git a/apps/api/plane/bgtasks/magic_link_code_task.py b/apps/api/plane/bgtasks/magic_link_code_task.py index d8267e697..eef7adea0 100644 --- a/apps/api/plane/bgtasks/magic_link_code_task.py +++ b/apps/api/plane/bgtasks/magic_link_code_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import logging @@ -8,10 +12,10 @@ from celery import shared_task # Third party imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags # Module imports 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 @@ -33,7 +37,7 @@ def magic_link(email, key, token): context = {"code": token, "email": email} html_content = render_to_string("emails/auth/magic_signin.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) connection = get_connection( host=EMAIL_HOST, diff --git a/apps/api/plane/bgtasks/notification_task.py b/apps/api/plane/bgtasks/notification_task.py index 6e571c0b1..bfb72afa3 100644 --- a/apps/api/plane/bgtasks/notification_task.py +++ b/apps/api/plane/bgtasks/notification_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json import uuid diff --git a/apps/api/plane/bgtasks/page_transaction_task.py b/apps/api/plane/bgtasks/page_transaction_task.py index 402d0a3ee..8c2cfe7a0 100644 --- a/apps/api/plane/bgtasks/page_transaction_task.py +++ b/apps/api/plane/bgtasks/page_transaction_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import logging @@ -88,7 +92,6 @@ def page_transaction(new_description_html, old_description_html, page_id): has_existing_logs = PageLog.objects.filter(page_id=page_id).exists() - # Extract all components in a single pass (optimized) old_components = extract_all_components(old_description_html) new_components = extract_all_components(new_description_html) @@ -125,12 +128,9 @@ def page_transaction(new_description_html, old_description_html, page_id): ) ) - # Bulk insert and cleanup if new_transactions: - PageLog.objects.bulk_create( - new_transactions, batch_size=50, ignore_conflicts=True - ) + PageLog.objects.bulk_create(new_transactions, batch_size=50, ignore_conflicts=True) if deleted_transaction_ids: PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete() diff --git a/apps/api/plane/bgtasks/page_version_task.py b/apps/api/plane/bgtasks/page_version_task.py index 4de2387be..7b41e3c44 100644 --- a/apps/api/plane/bgtasks/page_version_task.py +++ b/apps/api/plane/bgtasks/page_version_task.py @@ -1,37 +1,73 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json + # Third party imports from celery import shared_task +# Django imports +from django.utils import timezone + # Module imports from plane.db.models import Page, PageVersion from plane.utils.exception_logger import log_exception +PAGE_VERSION_TASK_TIMEOUT = 600 @shared_task -def page_version(page_id, existing_instance, user_id): +def track_page_version(page_id, existing_instance, user_id): try: # Get the page page = Page.objects.get(id=page_id) # Get the current instance current_instance = json.loads(existing_instance) if existing_instance is not None else {} + sub_pages = {} + # Create a version if description_html is updated if current_instance.get("description_html") != page.description_html: - # Create a new page version - PageVersion.objects.create( - page_id=page_id, - workspace_id=page.workspace_id, - description_html=page.description_html, - description_binary=page.description_binary, - owned_by_id=user_id, - last_saved_at=page.updated_at, - description_json=page.description, - description_stripped=page.description_stripped, - ) + # Fetch the latest page version + page_version = PageVersion.objects.filter(page_id=page_id).order_by("-last_saved_at").first() + # Get the latest page version if it exists and is owned by the user + if ( + page_version + and str(page_version.owned_by_id) == str(user_id) + and (timezone.now() - page_version.last_saved_at).total_seconds() <= PAGE_VERSION_TASK_TIMEOUT + ): + page_version.description_html = page.description_html + page_version.description_binary = page.description_binary + page_version.description_json = page.description + page_version.description_stripped = page.description_stripped + page_version.sub_pages_data = sub_pages + page_version.save( + update_fields=[ + "description_html", + "description_binary", + "description_json", + "description_stripped", + "sub_pages_data", + "updated_at" + ] + ) + else: + # Create a new page version + PageVersion.objects.create( + page_id=page_id, + workspace_id=page.workspace_id, + description_json=page.description, + description_html=page.description_html, + description_binary=page.description_binary, + description_stripped=page.description_stripped, + owned_by_id=user_id, + last_saved_at=timezone.now(), + sub_pages_data=sub_pages, + ) # If page versions are greater than 20 delete the oldest one if PageVersion.objects.filter(page_id=page_id).count() > 20: # Delete the old page version diff --git a/apps/api/plane/bgtasks/project_add_user_email_task.py b/apps/api/plane/bgtasks/project_add_user_email_task.py index af6014695..1efe6bc46 100644 --- a/apps/api/plane/bgtasks/project_add_user_email_task.py +++ b/apps/api/plane/bgtasks/project_add_user_email_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import logging @@ -7,11 +11,11 @@ from celery import shared_task # Third party imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags # Module imports 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.db.models import ProjectMember from plane.db.models import User @@ -55,7 +59,7 @@ def project_add_user_email(current_site, project_member_id, invitor_id): # Render the email template html_content = render_to_string("emails/notifications/project_addition.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) # Initialize the connection connection = get_connection( host=EMAIL_HOST, diff --git a/apps/api/plane/bgtasks/project_invitation_task.py b/apps/api/plane/bgtasks/project_invitation_task.py index b8eed5e45..86c10e90c 100644 --- a/apps/api/plane/bgtasks/project_invitation_task.py +++ b/apps/api/plane/bgtasks/project_invitation_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import logging @@ -8,11 +12,11 @@ from celery import shared_task # Third party imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags # Module imports from plane.db.models import Project, ProjectMemberInvite, User 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 @@ -33,11 +37,12 @@ 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) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) project_member_invite.message = text_content project_member_invite.save() diff --git a/apps/api/plane/bgtasks/recent_visited_task.py b/apps/api/plane/bgtasks/recent_visited_task.py index eda297ce4..3d4f9e6e9 100644 --- a/apps/api/plane/bgtasks/recent_visited_task.py +++ b/apps/api/plane/bgtasks/recent_visited_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports from django.utils import timezone from django.db import DatabaseError diff --git a/apps/api/plane/bgtasks/storage_metadata_task.py b/apps/api/plane/bgtasks/storage_metadata_task.py index ea745053f..77f99e916 100644 --- a/apps/api/plane/bgtasks/storage_metadata_task.py +++ b/apps/api/plane/bgtasks/storage_metadata_task.py @@ -1,3 +1,7 @@ +# 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 celery import shared_task diff --git a/apps/api/plane/bgtasks/user_activation_email_task.py b/apps/api/plane/bgtasks/user_activation_email_task.py index 492564b3c..f7a2d3999 100644 --- a/apps/api/plane/bgtasks/user_activation_email_task.py +++ b/apps/api/plane/bgtasks/user_activation_email_task.py @@ -1,10 +1,13 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import logging # Django imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags # Third party imports from celery import shared_task @@ -12,6 +15,7 @@ from celery import shared_task # Module imports from plane.db.models import User 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 @@ -27,7 +31,7 @@ def user_activation_email(current_site, user_id): # Send email to user html_content = render_to_string("emails/user/user_activation.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) # Configure email connection from the database ( EMAIL_HOST, diff --git a/apps/api/plane/bgtasks/user_deactivation_email_task.py b/apps/api/plane/bgtasks/user_deactivation_email_task.py index 2595d8055..81419606a 100644 --- a/apps/api/plane/bgtasks/user_deactivation_email_task.py +++ b/apps/api/plane/bgtasks/user_deactivation_email_task.py @@ -1,10 +1,13 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import logging # Django imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags # Third party imports from celery import shared_task @@ -12,6 +15,7 @@ from celery import shared_task # Module imports from plane.db.models import User 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 @@ -27,7 +31,7 @@ def user_deactivation_email(current_site, user_id): # Send email to user html_content = render_to_string("emails/user/user_deactivation.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) # Configure email connection from the database ( EMAIL_HOST, diff --git a/apps/api/plane/bgtasks/user_email_update_task.py b/apps/api/plane/bgtasks/user_email_update_task.py index 667de368c..48b9c02db 100644 --- a/apps/api/plane/bgtasks/user_email_update_task.py +++ b/apps/api/plane/bgtasks/user_email_update_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import logging @@ -7,10 +11,10 @@ from celery import shared_task # Django imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags # Module imports 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 @@ -32,7 +36,7 @@ def send_email_update_magic_code(email, token): context = {"code": token, "email": email} html_content = render_to_string("emails/auth/magic_signin.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) connection = get_connection( host=EMAIL_HOST, @@ -83,7 +87,7 @@ def send_email_update_confirmation(email): context = {"email": email} html_content = render_to_string("emails/user/email_updated.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) connection = get_connection( host=EMAIL_HOST, diff --git a/apps/api/plane/bgtasks/webhook_task.py b/apps/api/plane/bgtasks/webhook_task.py index 3d04a65b7..6543c3845 100644 --- a/apps/api/plane/bgtasks/webhook_task.py +++ b/apps/api/plane/bgtasks/webhook_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import hashlib import hmac import json @@ -16,7 +20,6 @@ from django.db.models import Prefetch from django.core.mail import EmailMultiAlternatives, get_connection from django.core.serializers.json import DjangoJSONEncoder from django.template.loader import render_to_string -from django.utils.html import strip_tags from django.core.exceptions import ObjectDoesNotExist # Module imports @@ -47,6 +50,7 @@ from plane.db.models import ( IssueAssignee, ) 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.settings.mongo import MongoConnection @@ -218,7 +222,7 @@ def send_webhook_deactivation_email(webhook_id: str, receiver_id: str, current_s "webhook_url": f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}", } html_content = render_to_string("emails/notifications/webhook-deactivate.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) # Set the email connection connection = get_connection( diff --git a/apps/api/plane/bgtasks/work_item_link_task.py b/apps/api/plane/bgtasks/work_item_link_task.py index 442396c7f..5cf0fbb19 100644 --- a/apps/api/plane/bgtasks/work_item_link_task.py +++ b/apps/api/plane/bgtasks/work_item_link_task.py @@ -13,7 +13,7 @@ from bs4 import BeautifulSoup from urllib.parse import urlparse, urljoin import base64 import ipaddress -from typing import Dict, Any +from typing import Dict, Any, Tuple from typing import Optional from plane.db.models import IssueLink from plane.utils.exception_logger import log_exception @@ -66,6 +66,52 @@ def validate_url_ip(url: str) -> None: MAX_REDIRECTS = 5 +def safe_get( + url: str, + headers: Optional[Dict[str, str]] = None, + timeout: int = 1, +) -> Tuple[requests.Response, str]: + """ + Perform a GET request that validates every redirect hop against private IPs. + Prevents SSRF by ensuring no redirect lands on a private/internal address. + + Args: + url: The URL to fetch + headers: Optional request headers + timeout: Request timeout in seconds + + Returns: + A tuple of (final Response object, final URL after redirects) + + Raises: + ValueError: If any URL in the redirect chain points to a private IP + requests.RequestException: On network errors + RuntimeError: If max redirects exceeded + """ + validate_url_ip(url) + + current_url = url + response = requests.get( + current_url, headers=headers, timeout=timeout, allow_redirects=False + ) + + redirect_count = 0 + while response.is_redirect: + if redirect_count >= MAX_REDIRECTS: + raise RuntimeError(f"Too many redirects for URL: {url}") + redirect_url = response.headers.get("Location") + if not redirect_url: + break + current_url = urljoin(current_url, redirect_url) + validate_url_ip(current_url) + redirect_count += 1 + response = requests.get( + current_url, headers=headers, timeout=timeout, allow_redirects=False + ) + + return response, current_url + + def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]: """ Crawls a URL to extract the title and favicon. @@ -86,26 +132,8 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]: title = None final_url = url - validate_url_ip(final_url) - try: - # Manually follow redirects to validate each URL before requesting - redirect_count = 0 - response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False) - - while response.is_redirect and redirect_count < MAX_REDIRECTS: - redirect_url = response.headers.get("Location") - if not redirect_url: - break - # Resolve relative redirects against current URL - final_url = urljoin(final_url, redirect_url) - # Validate the redirect target BEFORE making the request - validate_url_ip(final_url) - redirect_count += 1 - response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False) - - if redirect_count >= MAX_REDIRECTS: - logger.warning(f"Too many redirects for URL: {url}") + response, final_url = safe_get(url, headers=headers) soup = BeautifulSoup(response.content, "html.parser") title_tag = soup.find("title") @@ -113,8 +141,10 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]: except requests.RequestException as e: logger.warning(f"Failed to fetch HTML for title: {str(e)}") + except (ValueError, RuntimeError) as e: + logger.warning(f"URL validation failed: {str(e)}") - # Fetch and encode favicon using final URL (after redirects) + # Fetch and encode favicon using final URL (after redirects) for correct relative href resolution favicon_base64 = fetch_and_encode_favicon(headers, soup, final_url) # Prepare result @@ -204,9 +234,7 @@ def fetch_and_encode_favicon( "favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}", } - validate_url_ip(favicon_url) - - response = requests.get(favicon_url, headers=headers, timeout=1) + response, _ = safe_get(favicon_url, headers=headers) # Get content type content_type = response.headers.get("content-type", "image/x-icon") diff --git a/apps/api/plane/bgtasks/workspace_invitation_task.py b/apps/api/plane/bgtasks/workspace_invitation_task.py index f7480b36a..f293cc16f 100644 --- a/apps/api/plane/bgtasks/workspace_invitation_task.py +++ b/apps/api/plane/bgtasks/workspace_invitation_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import logging @@ -7,11 +11,11 @@ from celery import shared_task # Django imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string -from django.utils.html import strip_tags # Module imports from plane.db.models import User, Workspace, WorkspaceMemberInvite 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 @@ -25,7 +29,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter): # Relative link relative_link = ( - f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" # noqa: E501 + f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&slug={workspace.slug}&token={token}" # noqa: E501 ) # The complete url including the domain @@ -53,7 +57,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter): html_content = render_to_string("emails/invitations/workspace_invitation.html", context) - text_content = strip_tags(html_content) + text_content = generate_plain_text_from_html(html_content) workspace_member_invite.message = text_content workspace_member_invite.save() diff --git a/apps/api/plane/bgtasks/workspace_seed_task.py b/apps/api/plane/bgtasks/workspace_seed_task.py index 57ac02ec1..218ba2a71 100644 --- a/apps/api/plane/bgtasks/workspace_seed_task.py +++ b/apps/api/plane/bgtasks/workspace_seed_task.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os import json @@ -21,7 +25,7 @@ from plane.db.models import ( WorkspaceMember, Project, ProjectMember, - IssueUserProperty, + ProjectUserProperty, State, Label, Issue, @@ -94,7 +98,7 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int, project_seed.pop("name", None) project_seed.pop("identifier", None) - project = Project.objects.create( + project = Project( **project_seed, workspace=workspace, name=workspace.name, # Use workspace name @@ -105,58 +109,63 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int, module_view=True, issue_views_view=True, ) + project.save(created_by_id=bot_user.id, disable_auto_set_user=True) # Create project members - ProjectMember.objects.bulk_create([ - ProjectMember( - project=project, - member_id=workspace_member["member_id"], - role=workspace_member["role"], - workspace_id=workspace.id, - created_by_id=bot_user.id, - ) - for workspace_member in workspace_members - ]) + ProjectMember.objects.bulk_create( + [ + ProjectMember( + project=project, + member_id=workspace_member["member_id"], + role=workspace_member["role"], + workspace_id=workspace.id, + created_by_id=bot_user.id, + ) + for workspace_member in workspace_members + ] + ) # Create issue user properties - IssueUserProperty.objects.bulk_create([ - IssueUserProperty( - project=project, - user_id=workspace_member["member_id"], - workspace_id=workspace.id, - display_filters={ - "layout": "list", - "calendar": {"layout": "month", "show_weekends": False}, - "group_by": "state", - "order_by": "sort_order", - "sub_issue": True, - "sub_group_by": None, - "show_empty_groups": True, - }, - display_properties={ - "key": True, - "link": True, - "cycle": False, - "state": True, - "labels": False, - "modules": False, - "assignee": True, - "due_date": False, - "estimate": True, - "priority": True, - "created_on": True, - "issue_type": True, - "start_date": False, - "updated_on": True, - "customer_count": True, - "sub_issue_count": False, - "attachment_count": False, - "customer_request_count": True, - }, - created_by_id=bot_user.id, - ) - for workspace_member in workspace_members - ]) + ProjectUserProperty.objects.bulk_create( + [ + ProjectUserProperty( + project=project, + user_id=workspace_member["member_id"], + workspace_id=workspace.id, + display_filters={ + "layout": "list", + "calendar": {"layout": "month", "show_weekends": False}, + "group_by": "state", + "order_by": "sort_order", + "sub_issue": True, + "sub_group_by": None, + "show_empty_groups": True, + }, + display_properties={ + "key": True, + "link": True, + "cycle": False, + "state": True, + "labels": False, + "modules": False, + "assignee": True, + "due_date": False, + "estimate": True, + "priority": True, + "created_on": True, + "issue_type": True, + "start_date": False, + "updated_on": True, + "customer_count": True, + "sub_issue_count": False, + "attachment_count": False, + "customer_request_count": True, + }, + created_by_id=bot_user.id, + ) + for workspace_member in workspace_members + ] + ) # update map projects_map[project_id] = project.id logger.info(f"Task: workspace_seed_task -> Project {project_id} created") @@ -187,13 +196,13 @@ def create_project_states( state_id = state_seed.pop("id") project_id = state_seed.pop("project_id") - state = State.objects.create( + state = State( **state_seed, project_id=project_map[project_id], workspace=workspace, created_by_id=bot_user.id, ) - + state.save(created_by_id=bot_user.id, disable_auto_set_user=True) state_map[state_id] = state.id logger.info(f"Task: workspace_seed_task -> State {state_id} created") return state_map @@ -220,12 +229,13 @@ def create_project_labels( for label_seed in label_seeds: label_id = label_seed.pop("id") project_id = label_seed.pop("project_id") - label = Label.objects.create( + label = Label( **label_seed, project_id=project_map[project_id], workspace=workspace, created_by_id=bot_user.id, ) + label.save(created_by_id=bot_user.id, disable_auto_set_user=True) label_map[label_id] = label.id logger.info(f"Task: workspace_seed_task -> Label {label_id} created") @@ -272,13 +282,14 @@ def create_project_issues( cycle_id = issue_seed.pop("cycle_id") module_ids = issue_seed.pop("module_ids") - issue = Issue.objects.create( + issue = Issue( **issue_seed, state_id=states_map[state_id], project_id=project_map[project_id], workspace=workspace, created_by_id=bot_user.id, ) + issue.save(created_by_id=bot_user.id, disable_auto_set_user=True) IssueSequence.objects.create( issue=issue, project_id=project_map[project_id], @@ -347,12 +358,12 @@ def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_us for page_seed in page_seeds: page_id = page_seed.pop("id") - page = Page.objects.create( + page = Page( workspace_id=workspace.id, is_global=False, access=page_seed.get("access", Page.PUBLIC_ACCESS), name=page_seed.get("name"), - description=page_seed.get("description", {}), + description_json=page_seed.get("description_json", {}), description_html=page_seed.get("description_html", "

    "), description_binary=page_seed.get("description_binary", None), description_stripped=page_seed.get("description_stripped", None), @@ -361,16 +372,18 @@ def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_us owned_by_id=bot_user.id, ) + page.save(created_by_id=bot_user.id, disable_auto_set_user=True) + logger.info(f"Task: workspace_seed_task -> Page {page_id} created") if page_seed.get("project_id") and page_seed.get("type") == "PROJECT": - ProjectPage.objects.create( + project_page = ProjectPage( workspace_id=workspace.id, project_id=project_map[page_seed.get("project_id")], page_id=page.id, created_by_id=bot_user.id, updated_by_id=bot_user.id, ) - + project_page.save(created_by_id=bot_user.id, disable_auto_set_user=True) logger.info(f"Task: workspace_seed_task -> Project Page {page_id} created") return @@ -410,7 +423,7 @@ def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_u start_date = timezone.now() + timedelta(days=14) end_date = start_date + timedelta(days=14) - cycle = Cycle.objects.create( + cycle = Cycle( **cycle_seed, start_date=start_date, end_date=end_date, @@ -419,6 +432,7 @@ def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_u created_by_id=bot_user.id, owned_by_id=bot_user.id, ) + cycle.save(created_by_id=bot_user.id, disable_auto_set_user=True) cycle_map[cycle_id] = cycle.id logger.info(f"Task: workspace_seed_task -> Cycle {cycle_id} created") @@ -446,7 +460,7 @@ def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_ start_date = timezone.now() + timedelta(days=index * 2) end_date = start_date + timedelta(days=14) - module = Module.objects.create( + module = Module( **module_seed, start_date=start_date, target_date=end_date, @@ -454,6 +468,7 @@ def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_ workspace=workspace, created_by_id=bot_user.id, ) + module.save(created_by_id=bot_user.id, disable_auto_set_user=True) module_map[module_id] = module.id logger.info(f"Task: workspace_seed_task -> Module {module_id} created") return module_map @@ -474,13 +489,15 @@ def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_us for view_seed in view_seeds: project_id = view_seed.pop("project_id") - IssueView.objects.create( + view_seed.pop("id") + issue_view = IssueView( **view_seed, project_id=project_map[project_id], workspace=workspace, created_by_id=bot_user.id, owned_by_id=bot_user.id, ) + issue_view.save(created_by_id=bot_user.id, disable_auto_set_user=True) @shared_task @@ -514,6 +531,14 @@ def workspace_seed(workspace_id: uuid.UUID) -> None: is_password_autoset=True, ) + # Add bot user to workspace as member + WorkspaceMember.objects.create( + workspace=workspace, + member=bot_user, + role=20, + company_role="", + ) + # Create a project with the same name as workspace project_map = create_project_and_member(workspace, bot_user) diff --git a/apps/api/plane/celery.py b/apps/api/plane/celery.py index 828f4a6d5..562d04856 100644 --- a/apps/api/plane/celery.py +++ b/apps/api/plane/celery.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os import logging diff --git a/apps/api/plane/db/__init__.py b/apps/api/plane/db/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/db/__init__.py +++ b/apps/api/plane/db/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/db/apps.py b/apps/api/plane/db/apps.py index 7d4919d08..92c55908e 100644 --- a/apps/api/plane/db/apps.py +++ b/apps/api/plane/db/apps.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.apps import AppConfig diff --git a/apps/api/plane/db/management/__init__.py b/apps/api/plane/db/management/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/db/management/__init__.py +++ b/apps/api/plane/db/management/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/db/management/commands/__init__.py b/apps/api/plane/db/management/commands/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/db/management/commands/__init__.py +++ b/apps/api/plane/db/management/commands/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/db/management/commands/activate_user.py b/apps/api/plane/db/management/commands/activate_user.py index 5ebe8b740..3488a9865 100644 --- a/apps/api/plane/db/management/commands/activate_user.py +++ b/apps/api/plane/db/management/commands/activate_user.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.core.management import BaseCommand, CommandError diff --git a/apps/api/plane/db/management/commands/clear_cache.py b/apps/api/plane/db/management/commands/clear_cache.py index 1c66b3eaf..502778f1c 100644 --- a/apps/api/plane/db/management/commands/clear_cache.py +++ b/apps/api/plane/db/management/commands/clear_cache.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.core.cache import cache from django.core.management import BaseCommand diff --git a/apps/api/plane/db/management/commands/copy_issue_comment_to_description.py b/apps/api/plane/db/management/commands/copy_issue_comment_to_description.py index 8813f34db..ec106795b 100644 --- a/apps/api/plane/db/management/commands/copy_issue_comment_to_description.py +++ b/apps/api/plane/db/management/commands/copy_issue_comment_to_description.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.core.management.base import BaseCommand from django.db import transaction diff --git a/apps/api/plane/db/management/commands/create_bucket.py b/apps/api/plane/db/management/commands/create_bucket.py index 555fe0aa8..7a39a3a7f 100644 --- a/apps/api/plane/db/management/commands/create_bucket.py +++ b/apps/api/plane/db/management/commands/create_bucket.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os import boto3 diff --git a/apps/api/plane/db/management/commands/create_dummy_data.py b/apps/api/plane/db/management/commands/create_dummy_data.py index 220576b8f..c85c1e017 100644 --- a/apps/api/plane/db/management/commands/create_dummy_data.py +++ b/apps/api/plane/db/management/commands/create_dummy_data.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from typing import Any from django.core.management.base import BaseCommand, CommandError diff --git a/apps/api/plane/db/management/commands/create_instance_admin.py b/apps/api/plane/db/management/commands/create_instance_admin.py index 8d5a912e0..3834918d4 100644 --- a/apps/api/plane/db/management/commands/create_instance_admin.py +++ b/apps/api/plane/db/management/commands/create_instance_admin.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.core.management.base import BaseCommand, CommandError diff --git a/apps/api/plane/db/management/commands/create_project_member.py b/apps/api/plane/db/management/commands/create_project_member.py index d9b46524c..2bd975578 100644 --- a/apps/api/plane/db/management/commands/create_project_member.py +++ b/apps/api/plane/db/management/commands/create_project_member.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from typing import Any from django.core.management import BaseCommand, CommandError @@ -8,7 +12,7 @@ from plane.db.models import ( WorkspaceMember, ProjectMember, Project, - IssueUserProperty, + ProjectUserProperty, ) @@ -47,27 +51,18 @@ class Command(BaseCommand): if not WorkspaceMember.objects.filter(workspace=project.workspace, member=user, is_active=True).exists(): raise CommandError("User not member in workspace") - # Get the smallest sort order - smallest_sort_order = ( - ProjectMember.objects.filter(workspace_id=project.workspace_id).order_by("sort_order").first() - ) - - if smallest_sort_order: - sort_order = smallest_sort_order.sort_order - 1000 - else: - sort_order = 65535 if ProjectMember.objects.filter(project=project, member=user).exists(): # Update the project member ProjectMember.objects.filter(project=project, member=user).update( - is_active=True, sort_order=sort_order, role=role + is_active=True, role=role ) else: # Create the project member - ProjectMember.objects.create(project=project, member=user, role=role, sort_order=sort_order) + ProjectMember.objects.create(project=project, member=user, role=role) # Issue Property - IssueUserProperty.objects.get_or_create(user=user, project=project) + ProjectUserProperty.objects.get_or_create(user=user, project=project) # Success message self.stdout.write(self.style.SUCCESS(f"User {user_email} added to project {project_id}")) diff --git a/apps/api/plane/db/management/commands/fix_duplicate_sequences.py b/apps/api/plane/db/management/commands/fix_duplicate_sequences.py index 2b262606a..70624fbc2 100644 --- a/apps/api/plane/db/management/commands/fix_duplicate_sequences.py +++ b/apps/api/plane/db/management/commands/fix_duplicate_sequences.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.core.management.base import BaseCommand, CommandError from django.db.models import Max diff --git a/apps/api/plane/db/management/commands/reset_password.py b/apps/api/plane/db/management/commands/reset_password.py index 9e483f51e..5da607c6c 100644 --- a/apps/api/plane/db/management/commands/reset_password.py +++ b/apps/api/plane/db/management/commands/reset_password.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import getpass diff --git a/apps/api/plane/db/management/commands/sync_issue_description_version.py b/apps/api/plane/db/management/commands/sync_issue_description_version.py index 04e608a3c..0aac4bb15 100644 --- a/apps/api/plane/db/management/commands/sync_issue_description_version.py +++ b/apps/api/plane/db/management/commands/sync_issue_description_version.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.core.management.base import BaseCommand diff --git a/apps/api/plane/db/management/commands/sync_issue_version.py b/apps/api/plane/db/management/commands/sync_issue_version.py index 6c9a2cdac..a7ee98fa7 100644 --- a/apps/api/plane/db/management/commands/sync_issue_version.py +++ b/apps/api/plane/db/management/commands/sync_issue_version.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.core.management.base import BaseCommand diff --git a/apps/api/plane/db/management/commands/test_email.py b/apps/api/plane/db/management/commands/test_email.py index 22841a671..103b239b1 100644 --- a/apps/api/plane/db/management/commands/test_email.py +++ b/apps/api/plane/db/management/commands/test_email.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.core.mail import EmailMultiAlternatives, get_connection from django.core.management import BaseCommand, CommandError from django.template.loader import render_to_string diff --git a/apps/api/plane/db/management/commands/update_bucket.py b/apps/api/plane/db/management/commands/update_bucket.py index 47c28ff73..79f7eab4e 100644 --- a/apps/api/plane/db/management/commands/update_bucket.py +++ b/apps/api/plane/db/management/commands/update_bucket.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os import boto3 diff --git a/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py b/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py index 838325354..067afe231 100644 --- a/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py +++ b/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.core.management.base import BaseCommand from django.db import transaction from plane.db.models import Workspace diff --git a/apps/api/plane/db/management/commands/wait_for_db.py b/apps/api/plane/db/management/commands/wait_for_db.py index ec971f83a..8a9fdbc3d 100644 --- a/apps/api/plane/db/management/commands/wait_for_db.py +++ b/apps/api/plane/db/management/commands/wait_for_db.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import time from django.db import connections from django.db.utils import OperationalError diff --git a/apps/api/plane/db/management/commands/wait_for_migrations.py b/apps/api/plane/db/management/commands/wait_for_migrations.py index 13b251de5..b61d011b2 100644 --- a/apps/api/plane/db/management/commands/wait_for_migrations.py +++ b/apps/api/plane/db/management/commands/wait_for_migrations.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # wait_for_migrations.py import time from django.core.management.base import BaseCommand diff --git a/apps/api/plane/db/migrations/0113_webhook_version.py b/apps/api/plane/db/migrations/0113_webhook_version.py new file mode 100644 index 000000000..4ffbf3bb7 --- /dev/null +++ b/apps/api/plane/db/migrations/0113_webhook_version.py @@ -0,0 +1,66 @@ +# Generated by Django 4.2.26 on 2025-12-15 10:29 + +from django.db import migrations, models +import plane.db.models.workspace + + +def set_default_product_tour_to_false(): + return { + "work_items": False, + "cycles": False, + "modules": False, + "intake": False, + "pages": False, + } + +def get_default_product_tour(): + return { + "work_items": True, + "cycles": True, + "modules": True, + "intake": True, + "pages": True, + } + + +def populate_product_tour(apps, _schema_editor): + WorkspaceUserProperties = apps.get_model('db', 'WorkspaceUserProperties') + default_value = get_default_product_tour() + # Use bulk update for better performance + WorkspaceUserProperties.objects.all().update(product_tour=default_value) + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0112_auto_20251124_0603'), + ] + + operations = [ + migrations.AddField( + model_name='webhook', + name='version', + field=models.CharField(default='v1', max_length=50), + ), + migrations.AddField( + model_name='profile', + name='is_navigation_tour_completed', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='workspaceuserproperties', + name='product_tour', + field=models.JSONField(default=set_default_product_tour_to_false), + ), + migrations.AddField( + model_name='apitoken', + name='allowed_rate_limit', + field=models.CharField(default='60/min', max_length=255), + ), + migrations.AddField( + model_name='profile', + name='is_subscribed_to_changelog', + field=models.BooleanField(default=False), + ), + migrations.RunPython(populate_product_tour, reverse_code=migrations.RunPython.noop), + ] diff --git a/apps/api/plane/db/migrations/0114_projectuserproperty_delete_issueuserproperty_and_more.py b/apps/api/plane/db/migrations/0114_projectuserproperty_delete_issueuserproperty_and_more.py new file mode 100644 index 000000000..9a18fbafc --- /dev/null +++ b/apps/api/plane/db/migrations/0114_projectuserproperty_delete_issueuserproperty_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.22 on 2026-01-05 08:35 + +from django.db import migrations, models +import plane.db.models.project +import django.db.models.deletion +from django.conf import settings + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0113_webhook_version'), + ] + + operations = [ + migrations.AlterModelTable( + name='issueuserproperty', + table='project_user_properties', + ), + migrations.RenameModel( + old_name='IssueUserProperty', + new_name='ProjectUserProperty', + ), + migrations.AddField( + model_name='projectuserproperty', + name='preferences', + field=models.JSONField(default=plane.db.models.project.get_default_preferences), + ), + migrations.AddField( + model_name='projectuserproperty', + name='sort_order', + field=models.FloatField(default=65535), + ), + migrations.AlterModelOptions( + name='projectuserproperty', + options={'ordering': ('-created_at',), 'verbose_name': 'Project User Property', 'verbose_name_plural': 'Project User Properties'}, + ), + migrations.RemoveConstraint( + model_name='projectuserproperty', + name='issue_user_property_unique_user_project_when_deleted_at_null', + ), + migrations.AlterField( + model_name='projectuserproperty', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_property_user', to=settings.AUTH_USER_MODEL), + ), + migrations.AddConstraint( + model_name='projectuserproperty', + constraint=models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True)), fields=('user', 'project'), name='project_user_property_unique_user_project_when_deleted_at_null'), + ), + ] \ No newline at end of file diff --git a/apps/api/plane/db/migrations/0115_auto_20260105_1406.py b/apps/api/plane/db/migrations/0115_auto_20260105_1406.py new file mode 100644 index 000000000..b9ac71d47 --- /dev/null +++ b/apps/api/plane/db/migrations/0115_auto_20260105_1406.py @@ -0,0 +1,51 @@ +# Generated by Django 4.2.22 on 2026-01-05 08:36 + +from django.db import migrations + +def move_issue_user_properties_to_project_user_properties(apps, schema_editor): + ProjectMember = apps.get_model('db', 'ProjectMember') + ProjectUserProperty = apps.get_model('db', 'ProjectUserProperty') + + # Get all project members + project_members = ProjectMember.objects.filter(deleted_at__isnull=True).values('member_id', 'project_id', 'preferences', 'sort_order') + + # create a mapping with consistent ordering + pm_dict = { + (pm['member_id'], pm['project_id']): pm + for pm in project_members + } + + # Get all project user properties + properties_to_update = [] + for projectuserproperty in ProjectUserProperty.objects.filter(deleted_at__isnull=True): + pm = pm_dict.get((projectuserproperty.user_id, projectuserproperty.project_id)) + if pm: + projectuserproperty.preferences = pm['preferences'] + projectuserproperty.sort_order = pm['sort_order'] + properties_to_update.append(projectuserproperty) + + ProjectUserProperty.objects.bulk_update(properties_to_update, ['preferences', 'sort_order'], batch_size=2000) + + + +def migrate_existing_api_tokens(apps, schema_editor): + APIToken = apps.get_model('db', 'APIToken') + + # Update all the existing non-service api tokens to not have a workspace + APIToken.objects.filter(is_service=False, user__is_bot=False).update( + workspace_id=None, + + ) + return + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0114_projectuserproperty_delete_issueuserproperty_and_more'), + ] + + operations = [ + migrations.RunPython(move_issue_user_properties_to_project_user_properties, reverse_code=migrations.RunPython.noop), + migrations.RunPython(migrate_existing_api_tokens, reverse_code=migrations.RunPython.noop), + ] diff --git a/apps/api/plane/db/migrations/0116_workspacemember_explored_features_and_more.py b/apps/api/plane/db/migrations/0116_workspacemember_explored_features_and_more.py new file mode 100644 index 000000000..38e231e0e --- /dev/null +++ b/apps/api/plane/db/migrations/0116_workspacemember_explored_features_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.27 on 2026-01-13 10:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0115_auto_20260105_1406'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='notification_view_mode', + field=models.CharField(choices=[('full', 'Full'), ('compact', 'Compact')], default='full', max_length=255), + ), + migrations.AddField( + model_name='user', + name='is_password_reset_required', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='workspacemember', + name='explored_features', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='workspacemember', + name='getting_started_checklist', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='workspacemember', + name='tips', + field=models.JSONField(default=dict), + ), + ] diff --git a/apps/api/plane/db/migrations/0117_rename_description_draftissue_description_json_and_more.py b/apps/api/plane/db/migrations/0117_rename_description_draftissue_description_json_and_more.py new file mode 100644 index 000000000..2317a4cdd --- /dev/null +++ b/apps/api/plane/db/migrations/0117_rename_description_draftissue_description_json_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.22 on 2026-01-15 09:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0116_workspacemember_explored_features_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='draftissue', + old_name='description', + new_name='description_json', + ), + migrations.RenameField( + model_name='issue', + old_name='description', + new_name='description_json', + ), + migrations.RenameField( + model_name='page', + old_name='description', + new_name='description_json', + ), + ] diff --git a/apps/api/plane/db/migrations/0118_remove_workspaceuserproperties_product_tour_and_more.py b/apps/api/plane/db/migrations/0118_remove_workspaceuserproperties_product_tour_and_more.py new file mode 100644 index 000000000..9a2b39edf --- /dev/null +++ b/apps/api/plane/db/migrations/0118_remove_workspaceuserproperties_product_tour_and_more.py @@ -0,0 +1,71 @@ +# Generated by Django 4.2.27 on 2026-01-23 09:27 + +from django.db import migrations, models +import plane.db.models.user + +def set_getting_started_checklist(): + return { + "project_created": True, + "project_joined": True, + "work_item_created": True, + "team_members_invited": True, + "page_created": True, + "ai_chat_tried": True, + "integration_linked": True, + "view_created": True, + "sticky_created": True, + } + +def set_default_tips(): + return {"mobile_app_download": True} + + +def set_default_explored_features(): + return {"github_integrated": True, "slack_integrated": True, "ai_chat_tried": True} + + +def set_default_product_tour(): + return { + "work_items": True, + "cycles": True, + "modules": True, + "intake": True, + "pages": True, + } + + +def migrate_all_the_product_tour_to_true(apps, _schema_editor): + Profile = apps.get_model('db', 'Profile') + WorkspaceMember = apps.get_model('db', 'WorkspaceMember') + + default_checklist_values = set_getting_started_checklist() + default_tips_values = set_default_tips() + default_explored_features = set_default_explored_features() + default_product_tour = set_default_product_tour() + + Profile.objects.all().update(is_navigation_tour_completed=True) + WorkspaceMember.objects.all().update(getting_started_checklist=default_checklist_values) + WorkspaceMember.objects.all().update(tips=default_tips_values) + WorkspaceMember.objects.all().update(explored_features=default_explored_features) + Profile.objects.all().update(product_tour=default_product_tour) + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0117_rename_description_draftissue_description_json_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='workspaceuserproperties', + name='product_tour', + ), + migrations.AddField( + model_name='profile', + name='product_tour', + field=models.JSONField(default=plane.db.models.user.get_default_product_tour), + ), + migrations.RunPython(migrate_all_the_product_tour_to_true, reverse_code=migrations.RunPython.noop) + + ] diff --git a/apps/api/plane/db/migrations/0119_alter_estimatepoint_key.py b/apps/api/plane/db/migrations/0119_alter_estimatepoint_key.py new file mode 100644 index 000000000..a730808a1 --- /dev/null +++ b/apps/api/plane/db/migrations/0119_alter_estimatepoint_key.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.27 on 2026-02-09 09:37 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0118_remove_workspaceuserproperties_product_tour_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='estimatepoint', + name='key', + field=models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/apps/api/plane/db/migrations/0120_issueview_archived_at.py b/apps/api/plane/db/migrations/0120_issueview_archived_at.py new file mode 100644 index 000000000..4357766d4 --- /dev/null +++ b/apps/api/plane/db/migrations/0120_issueview_archived_at.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.28 on 2026-02-17 10:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0119_alter_estimatepoint_key'), + ] + + operations = [ + migrations.AddField( + model_name='issueview', + name='archived_at', + field=models.DateTimeField(null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0121_alter_estimate_type.py b/apps/api/plane/db/migrations/0121_alter_estimate_type.py new file mode 100644 index 000000000..73b75123f --- /dev/null +++ b/apps/api/plane/db/migrations/0121_alter_estimate_type.py @@ -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), + ), + ] diff --git a/apps/api/plane/db/mixins.py b/apps/api/plane/db/mixins.py index be5613b61..b36269959 100644 --- a/apps/api/plane/db/mixins.py +++ b/apps/api/plane/db/mixins.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Type imports from typing import Any @@ -188,3 +192,30 @@ class ChangeTrackerMixin: all non-deferred fields). """ return self._original_values + + def save(self, *args: Any, **kwargs: Any) -> None: + """ + Override save to automatically capture changed fields and reset tracking. + + Before saving, the current changed_fields are captured and stored in + _changes_on_save. After saving, the tracked fields are reset so + that subsequent saves correctly detect changes relative to the last + saved state, not the original load-time state. + + Models that need to access the changed fields after save (e.g., for + syncing related models) can use self._changes_on_save. + """ + self._changes_on_save = self.changed_fields + super().save(*args, **kwargs) + self._reset_tracked_fields() + + def _reset_tracked_fields(self) -> None: + """ + Reset the tracked field values to the current state. + + This is called automatically after save() to ensure that subsequent + saves correctly detect changes relative to the last saved state, + rather than the original load-time state. + """ + self._original_values = {} + self._track_fields() diff --git a/apps/api/plane/db/models/__init__.py b/apps/api/plane/db/models/__init__.py index 41fd32bd5..5cf9dec2a 100644 --- a/apps/api/plane/db/models/__init__.py +++ b/apps/api/plane/db/models/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .analytic import AnalyticView from .api import APIActivityLog, APIToken from .asset import FileAsset @@ -34,7 +38,6 @@ from .issue import ( IssueLabel, IssueLink, IssueMention, - IssueUserProperty, IssueReaction, IssueRelation, IssueSequence, @@ -54,6 +57,7 @@ from .project import ( ProjectMemberInvite, ProjectNetwork, ProjectPublicMember, + ProjectUserProperty, ) from .session import Session from .social_connection import SocialLoginConnection diff --git a/apps/api/plane/db/models/analytic.py b/apps/api/plane/db/models/analytic.py index 0efcb957f..601ef9ea5 100644 --- a/apps/api/plane/db/models/analytic.py +++ b/apps/api/plane/db/models/analytic.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django models from django.db import models diff --git a/apps/api/plane/db/models/api.py b/apps/api/plane/db/models/api.py index 7d040ebc2..c545860c0 100644 --- a/apps/api/plane/db/models/api.py +++ b/apps/api/plane/db/models/api.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports from uuid import uuid4 @@ -32,6 +36,7 @@ class APIToken(BaseModel): workspace = models.ForeignKey("db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True) expired_at = models.DateTimeField(blank=True, null=True) is_service = models.BooleanField(default=False) + allowed_rate_limit = models.CharField(max_length=255, default="60/min") class Meta: verbose_name = "API Token" diff --git a/apps/api/plane/db/models/asset.py b/apps/api/plane/db/models/asset.py index ed9879a73..d309135bc 100644 --- a/apps/api/plane/db/models/asset.py +++ b/apps/api/plane/db/models/asset.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports from uuid import uuid4 diff --git a/apps/api/plane/db/models/base.py b/apps/api/plane/db/models/base.py index 468af8261..482dc9063 100644 --- a/apps/api/plane/db/models/base.py +++ b/apps/api/plane/db/models/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import uuid # Django imports diff --git a/apps/api/plane/db/models/cycle.py b/apps/api/plane/db/models/cycle.py index bdffd283d..78ea977d9 100644 --- a/apps/api/plane/db/models/cycle.py +++ b/apps/api/plane/db/models/cycle.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import pytz diff --git a/apps/api/plane/db/models/deploy_board.py b/apps/api/plane/db/models/deploy_board.py index da9c0d698..b9d8778e0 100644 --- a/apps/api/plane/db/models/deploy_board.py +++ b/apps/api/plane/db/models/deploy_board.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports from uuid import uuid4 diff --git a/apps/api/plane/db/models/description.py b/apps/api/plane/db/models/description.py index 6c298546a..0e8de3ce7 100644 --- a/apps/api/plane/db/models/description.py +++ b/apps/api/plane/db/models/description.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.db import models from django.utils.html import strip_tags from .workspace import WorkspaceBaseModel diff --git a/apps/api/plane/db/models/device.py b/apps/api/plane/db/models/device.py index adcf7974a..9254a21ff 100644 --- a/apps/api/plane/db/models/device.py +++ b/apps/api/plane/db/models/device.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # models.py from django.db import models from django.conf import settings diff --git a/apps/api/plane/db/models/draft.py b/apps/api/plane/db/models/draft.py index 55dbb61df..2d126da22 100644 --- a/apps/api/plane/db/models/draft.py +++ b/apps/api/plane/db/models/draft.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.conf import settings from django.db import models @@ -39,7 +43,7 @@ class DraftIssue(WorkspaceBaseModel): blank=True, ) name = models.CharField(max_length=255, verbose_name="Issue Name", blank=True, null=True) - description = models.JSONField(blank=True, default=dict) + description_json = models.JSONField(blank=True, default=dict) description_html = models.TextField(blank=True, default="

    ") description_stripped = models.TextField(blank=True, null=True) description_binary = models.BinaryField(null=True) diff --git a/apps/api/plane/db/models/estimate.py b/apps/api/plane/db/models/estimate.py index 9373fb320..fb472a69b 100644 --- a/apps/api/plane/db/models/estimate.py +++ b/apps/api/plane/db/models/estimate.py @@ -1,16 +1,24 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# 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): @@ -34,7 +42,7 @@ class Estimate(ProjectBaseModel): class EstimatePoint(ProjectBaseModel): estimate = models.ForeignKey("db.Estimate", on_delete=models.CASCADE, related_name="points") - key = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]) + key = models.IntegerField(default=0, validators=[MinValueValidator(0)]) description = models.TextField(blank=True) value = models.CharField(max_length=255) diff --git a/apps/api/plane/db/models/exporter.py b/apps/api/plane/db/models/exporter.py index 8ad9daad7..7abfe63af 100644 --- a/apps/api/plane/db/models/exporter.py +++ b/apps/api/plane/db/models/exporter.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import uuid # Python imports diff --git a/apps/api/plane/db/models/favorite.py b/apps/api/plane/db/models/favorite.py index de2b101a0..1ce29da87 100644 --- a/apps/api/plane/db/models/favorite.py +++ b/apps/api/plane/db/models/favorite.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.conf import settings # Django imports diff --git a/apps/api/plane/db/models/importer.py b/apps/api/plane/db/models/importer.py index 9bcea8cf0..24d987bb7 100644 --- a/apps/api/plane/db/models/importer.py +++ b/apps/api/plane/db/models/importer.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.conf import settings from django.db import models diff --git a/apps/api/plane/db/models/intake.py b/apps/api/plane/db/models/intake.py index c3369ae1d..700d5d8cf 100644 --- a/apps/api/plane/db/models/intake.py +++ b/apps/api/plane/db/models/intake.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.db import models diff --git a/apps/api/plane/db/models/integration/__init__.py b/apps/api/plane/db/models/integration/__init__.py index 34b40e57d..2242b4ddd 100644 --- a/apps/api/plane/db/models/integration/__init__.py +++ b/apps/api/plane/db/models/integration/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .base import Integration, WorkspaceIntegration from .github import ( GithubRepository, diff --git a/apps/api/plane/db/models/integration/base.py b/apps/api/plane/db/models/integration/base.py index 296c3cf6d..d98aa292d 100644 --- a/apps/api/plane/db/models/integration/base.py +++ b/apps/api/plane/db/models/integration/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import uuid diff --git a/apps/api/plane/db/models/integration/github.py b/apps/api/plane/db/models/integration/github.py index ba278497e..8d84dbe3e 100644 --- a/apps/api/plane/db/models/integration/github.py +++ b/apps/api/plane/db/models/integration/github.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports # Django imports diff --git a/apps/api/plane/db/models/integration/slack.py b/apps/api/plane/db/models/integration/slack.py index 1e8ea469b..f1c33f5c2 100644 --- a/apps/api/plane/db/models/integration/slack.py +++ b/apps/api/plane/db/models/integration/slack.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports # Django imports diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py index d3377f0ad..d24efc8a2 100644 --- a/apps/api/plane/db/models/issue.py +++ b/apps/api/plane/db/models/issue.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python import from uuid import uuid4 @@ -90,14 +94,6 @@ class IssueManager(SoftDeletionManager): return ( super() .get_queryset() - .filter( - models.Q(issue_intake__status=1) - | models.Q(issue_intake__status=-1) - | models.Q(issue_intake__status=2) - | models.Q(issue_intake__isnull=True) - ) - .filter(deleted_at__isnull=True) - .filter(state__is_triage=False) .exclude(state__group=StateGroup.TRIAGE.value) .exclude(archived_at__isnull=False) .exclude(project__archived_at__isnull=False) @@ -136,7 +132,7 @@ class Issue(ProjectBaseModel): blank=True, ) name = models.CharField(max_length=255, verbose_name="Issue Name") - description = models.JSONField(blank=True, default=dict) + description_json = models.JSONField(blank=True, default=dict) description_html = models.TextField(blank=True, default="

    ") description_stripped = models.TextField(blank=True, null=True) description_binary = models.BinaryField(null=True) @@ -207,39 +203,35 @@ class Issue(ProjectBaseModel): if self._state.adding: with transaction.atomic(): - # Create a lock for this specific project using an advisory lock + # Create a lock for this specific project using a transaction-level advisory lock # This ensures only one transaction per project can execute this code at a time + # The lock is automatically released when the transaction ends lock_key = convert_uuid_to_integer(self.project.id) with connection.cursor() as cursor: - # Get an exclusive lock using the project ID as the lock key - cursor.execute("SELECT pg_advisory_lock(%s)", [lock_key]) + # Get an exclusive transaction-level lock using the project ID as the lock key + cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key]) - try: - # Get the last sequence for the project - last_sequence = IssueSequence.objects.filter(project=self.project).aggregate( - largest=models.Max("sequence") - )["largest"] - self.sequence_id = last_sequence + 1 if last_sequence else 1 - # Strip the html tags using html parser - self.description_stripped = ( - None - if (self.description_html == "" or self.description_html is None) - else strip_tags(self.description_html) - ) - largest_sort_order = Issue.objects.filter(project=self.project, state=self.state).aggregate( - largest=models.Max("sort_order") - )["largest"] - if largest_sort_order is not None: - self.sort_order = largest_sort_order + 10000 + # Get the last sequence for the project + last_sequence = IssueSequence.objects.filter(project=self.project).aggregate( + largest=models.Max("sequence") + )["largest"] + self.sequence_id = last_sequence + 1 if last_sequence else 1 + # Strip the html tags using html parser + self.description_stripped = ( + None + if (self.description_html == "" or self.description_html is None) + else strip_tags(self.description_html) + ) + largest_sort_order = Issue.objects.filter(project=self.project, state=self.state).aggregate( + largest=models.Max("sort_order") + )["largest"] + if largest_sort_order is not None: + self.sort_order = largest_sort_order + 10000 - super(Issue, self).save(*args, **kwargs) + super(Issue, self).save(*args, **kwargs) - IssueSequence.objects.create(issue=self, sequence=self.sequence_id, project=self.project) - finally: - # Release the lock - with connection.cursor() as cursor: - cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key]) + IssueSequence.objects.create(issue=self, sequence=self.sequence_id, project=self.project) else: # Strip the html tags using html parser self.description_stripped = ( @@ -513,10 +505,12 @@ class IssueComment(ChangeTrackerMixin, ProjectBaseModel): "comment_json": "description_json", } + # Use _changes_on_save which is captured by ChangeTrackerMixin.save() + # before the tracked fields are reset changed_fields = { desc_field: getattr(self, comment_field) for comment_field, desc_field in field_mapping.items() - if self.has_changed(comment_field) + if comment_field in self._changes_on_save } # Update description only if comment fields changed @@ -536,36 +530,6 @@ class IssueComment(ChangeTrackerMixin, ProjectBaseModel): return str(self.issue) -class IssueUserProperty(ProjectBaseModel): - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="issue_property_user", - ) - filters = models.JSONField(default=get_default_filters) - display_filters = models.JSONField(default=get_default_display_filters) - display_properties = models.JSONField(default=get_default_display_properties) - rich_filters = models.JSONField(default=dict) - - class Meta: - verbose_name = "Issue User Property" - verbose_name_plural = "Issue User Properties" - db_table = "issue_user_properties" - ordering = ("-created_at",) - unique_together = ["user", "project", "deleted_at"] - constraints = [ - models.UniqueConstraint( - fields=["user", "project"], - condition=Q(deleted_at__isnull=True), - name="issue_user_property_unique_user_project_when_deleted_at_null", - ) - ] - - def __str__(self): - """Return properties status of the issue""" - return str(self.user) - - class IssueLabel(ProjectBaseModel): issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="label_issue") label = models.ForeignKey("db.Label", on_delete=models.CASCADE, related_name="label_issue") @@ -840,7 +804,7 @@ class IssueDescriptionVersion(ProjectBaseModel): description_binary=issue.description_binary, description_html=issue.description_html, description_stripped=issue.description_stripped, - description_json=issue.description, + description_json=issue.description_json, ) return True except Exception as e: diff --git a/apps/api/plane/db/models/issue_type.py b/apps/api/plane/db/models/issue_type.py index 4f3dc08de..94eaf50bf 100644 --- a/apps/api/plane/db/models/issue_type.py +++ b/apps/api/plane/db/models/issue_type.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.db import models from django.db.models import Q diff --git a/apps/api/plane/db/models/label.py b/apps/api/plane/db/models/label.py index 76ecf10e6..9435e01c6 100644 --- a/apps/api/plane/db/models/label.py +++ b/apps/api/plane/db/models/label.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.db import models from django.db.models import Q diff --git a/apps/api/plane/db/models/module.py b/apps/api/plane/db/models/module.py index ab62f2df5..d660116fa 100644 --- a/apps/api/plane/db/models/module.py +++ b/apps/api/plane/db/models/module.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.conf import settings from django.db import models diff --git a/apps/api/plane/db/models/notification.py b/apps/api/plane/db/models/notification.py index fd97a3c96..c24135854 100644 --- a/apps/api/plane/db/models/notification.py +++ b/apps/api/plane/db/models/notification.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.conf import settings from django.db import models diff --git a/apps/api/plane/db/models/page.py b/apps/api/plane/db/models/page.py index 213954d14..2c82c5f44 100644 --- a/apps/api/plane/db/models/page.py +++ b/apps/api/plane/db/models/page.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import uuid from django.conf import settings @@ -25,7 +29,7 @@ class Page(BaseModel): workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="pages") name = models.TextField(blank=True) - description = models.JSONField(default=dict, blank=True) + description_json = models.JSONField(default=dict, blank=True) description_binary = models.BinaryField(null=True) description_html = models.TextField(blank=True, default="

    ") description_stripped = models.TextField(blank=True, null=True) diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py index 8495ac9df..4039b1d29 100644 --- a/apps/api/plane/db/models/project.py +++ b/apps/api/plane/db/models/project.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import pytz from uuid import uuid4 @@ -12,7 +16,6 @@ from django.db.models import Q # Module imports from plane.db.mixins import AuditModel -# Module imports from .base import BaseModel ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest")) @@ -116,6 +119,11 @@ class Project(BaseModel): external_source = models.CharField(max_length=255, null=True, blank=True) external_id = models.CharField(max_length=255, blank=True, null=True) + def __init__(self, *args, **kwargs): + # Track if timezone is provided, if so, don't override it with the workspace timezone when saving + self.is_timezone_provided = kwargs.get("timezone") is not None + super().__init__(*args, **kwargs) + @property def cover_image_url(self): # Return cover image url @@ -132,6 +140,8 @@ class Project(BaseModel): """Return name of the project""" return f"{self.name} <{self.workspace.name}>" + FORBIDDEN_IDENTIFIER_CHARS_PATTERN = r"^.*[&+,:;$^}{*=?@#|'<>.()%!-].*$" + class Meta: unique_together = [ ["identifier", "workspace", "deleted_at"], @@ -155,7 +165,15 @@ class Project(BaseModel): ordering = ("-created_at",) def save(self, *args, **kwargs): + from plane.db.models import Workspace + self.identifier = self.identifier.strip().upper() + is_creating = self._state.adding + + if is_creating and not self.is_timezone_provided: + workspace = Workspace.objects.get(id=self.workspace_id) + self.timezone = workspace.timezone + return super().save(*args, **kwargs) @@ -206,14 +224,20 @@ class ProjectMember(ProjectBaseModel): is_active = models.BooleanField(default=True) def save(self, *args, **kwargs): - if self._state.adding: - smallest_sort_order = ProjectMember.objects.filter( - workspace_id=self.project.workspace_id, member=self.member - ).aggregate(smallest=models.Min("sort_order"))["smallest"] + if self._state.adding and self.member: + # Get the minimum sort_order for this member in the workspace + min_sort_order_result = ProjectUserProperty.objects.filter( + workspace_id=self.project.workspace_id, user=self.member + ).aggregate(min_sort_order=models.Min("sort_order")) + min_sort_order = min_sort_order_result.get("min_sort_order") - # Project ordering - if smallest_sort_order is not None: - self.sort_order = smallest_sort_order - 10000 + # create project user property with project sort order + ProjectUserProperty.objects.create( + workspace_id=self.project.workspace_id, + project=self.project, + user=self.member, + sort_order=(min_sort_order - 10000 if min_sort_order is not None else 65535), + ) super(ProjectMember, self).save(*args, **kwargs) @@ -313,3 +337,37 @@ class ProjectPublicMember(ProjectBaseModel): verbose_name_plural = "Project Public Members" db_table = "project_public_members" ordering = ("-created_at",) + + +class ProjectUserProperty(ProjectBaseModel): + from .issue import get_default_filters, get_default_display_filters, get_default_display_properties + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="project_property_user", + ) + filters = models.JSONField(default=get_default_filters) + display_filters = models.JSONField(default=get_default_display_filters) + display_properties = models.JSONField(default=get_default_display_properties) + rich_filters = models.JSONField(default=dict) + preferences = models.JSONField(default=get_default_preferences) + sort_order = models.FloatField(default=65535) + + class Meta: + verbose_name = "Project User Property" + verbose_name_plural = "Project User Properties" + db_table = "project_user_properties" + ordering = ("-created_at",) + unique_together = ["user", "project", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["user", "project"], + condition=Q(deleted_at__isnull=True), + name="project_user_property_unique_user_project_when_deleted_at_null", + ) + ] + + def __str__(self): + """Return properties status of the project""" + return str(self.user) diff --git a/apps/api/plane/db/models/recent_visit.py b/apps/api/plane/db/models/recent_visit.py index 42855081b..fb368fa12 100644 --- a/apps/api/plane/db/models/recent_visit.py +++ b/apps/api/plane/db/models/recent_visit.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.db import models from django.conf import settings diff --git a/apps/api/plane/db/models/session.py b/apps/api/plane/db/models/session.py index e884498bf..52b885ee9 100644 --- a/apps/api/plane/db/models/session.py +++ b/apps/api/plane/db/models/session.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import string diff --git a/apps/api/plane/db/models/social_connection.py b/apps/api/plane/db/models/social_connection.py index 9a85a320d..7e8ee8c2c 100644 --- a/apps/api/plane/db/models/social_connection.py +++ b/apps/api/plane/db/models/social_connection.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.conf import settings from django.db import models diff --git a/apps/api/plane/db/models/state.py b/apps/api/plane/db/models/state.py index aeb08b8b2..fa56900c3 100644 --- a/apps/api/plane/db/models/state.py +++ b/apps/api/plane/db/models/state.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.db import models from django.template.defaultfilters import slugify @@ -5,7 +9,7 @@ from django.db.models import Q # Module imports from .project import ProjectBaseModel - +from plane.db.mixins import SoftDeletionManager class StateGroup(models.TextChoices): BACKLOG = "backlog", "Backlog" @@ -58,14 +62,14 @@ DEFAULT_STATES = [ ] -class StateManager(models.Manager): +class StateManager(SoftDeletionManager): """Default manager - excludes triage states""" def get_queryset(self): return super().get_queryset().exclude(group=StateGroup.TRIAGE.value) -class TriageStateManager(models.Manager): +class TriageStateManager(SoftDeletionManager): """Manager for triage states only""" def get_queryset(self): diff --git a/apps/api/plane/db/models/sticky.py b/apps/api/plane/db/models/sticky.py index 157077eb8..757cb8ea1 100644 --- a/apps/api/plane/db/models/sticky.py +++ b/apps/api/plane/db/models/sticky.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.conf import settings from django.db import models diff --git a/apps/api/plane/db/models/user.py b/apps/api/plane/db/models/user.py index ee70032cf..7f1ab162d 100644 --- a/apps/api/plane/db/models/user.py +++ b/apps/api/plane/db/models/user.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import random import string @@ -35,6 +39,16 @@ def get_mobile_default_onboarding(): } +def get_default_product_tour(): + return { + "work_items": False, + "cycles": False, + "modules": False, + "intake": False, + "pages": False, + } + + class BotTypeEnum(models.TextChoices): WORKSPACE_SEED = "WORKSPACE_SEED", "Workspace Seed" @@ -84,7 +98,7 @@ class User(AbstractBaseUser, PermissionsMixin): is_staff = models.BooleanField(default=False) is_email_verified = models.BooleanField(default=False) is_password_autoset = models.BooleanField(default=False) - + is_password_reset_required = models.BooleanField(default=False) # random token generated token = models.CharField(max_length=64, blank=True) @@ -147,6 +161,11 @@ class User(AbstractBaseUser, PermissionsMixin): return self.cover_image return None + @property + def full_name(self): + """Return user's full name (first + last).""" + return f"{self.first_name} {self.last_name}".strip() + def save(self, *args, **kwargs): self.email = self.email.lower().strip() self.mobile_number = self.mobile_number @@ -167,6 +186,16 @@ class User(AbstractBaseUser, PermissionsMixin): super(User, self).save(*args, **kwargs) + @classmethod + def get_display_name(cls, email): + if not email: + return "".join(random.choice(string.ascii_letters) for _ in range(6)) + return ( + email.split("@")[0] + if len(email.split("@")) == 2 + else "".join(random.choice(string.ascii_letters) for _ in range(6)) + ) + class Profile(TimeAuditModel): SUNDAY = 0 @@ -177,6 +206,10 @@ class Profile(TimeAuditModel): FRIDAY = 5 SATURDAY = 6 + class NotificationViewMode(models.TextChoices): + FULL = "full", "Full" + COMPACT = "compact", "Compact" + START_OF_THE_WEEK_CHOICES = ( (SUNDAY, "Sunday"), (MONDAY, "Monday"), @@ -206,7 +239,9 @@ class Profile(TimeAuditModel): billing_address = models.JSONField(null=True) has_billing_address = models.BooleanField(default=False) company_name = models.CharField(max_length=255, blank=True) - + notification_view_mode = models.CharField( + max_length=255, choices=NotificationViewMode.choices, default=NotificationViewMode.FULL + ) is_smooth_cursor_enabled = models.BooleanField(default=False) # mobile is_mobile_onboarded = models.BooleanField(default=False) @@ -218,8 +253,13 @@ class Profile(TimeAuditModel): goals = models.JSONField(default=dict) background_color = models.CharField(max_length=255, default=get_random_color) + # navigation tour + is_navigation_tour_completed = models.BooleanField(default=False) + # marketing has_marketing_email_consent = models.BooleanField(default=False) + is_subscribed_to_changelog = models.BooleanField(default=False) + product_tour = models.JSONField(default=get_default_product_tour) class Meta: verbose_name = "Profile" diff --git a/apps/api/plane/db/models/view.py b/apps/api/plane/db/models/view.py index d430cd5f9..a02b768a3 100644 --- a/apps/api/plane/db/models/view.py +++ b/apps/api/plane/db/models/view.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.conf import settings from django.db import models @@ -64,6 +68,7 @@ class IssueView(WorkspaceBaseModel): logo_props = models.JSONField(default=dict) owned_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="views") is_locked = models.BooleanField(default=False) + archived_at = models.DateTimeField(null=True) class Meta: verbose_name = "Issue View" diff --git a/apps/api/plane/db/models/webhook.py b/apps/api/plane/db/models/webhook.py index 8872d0bb2..99431ed42 100644 --- a/apps/api/plane/db/models/webhook.py +++ b/apps/api/plane/db/models/webhook.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports from uuid import uuid4 from urllib.parse import urlparse @@ -38,6 +42,7 @@ class Webhook(BaseModel): cycle = models.BooleanField(default=False) issue_comment = models.BooleanField(default=False) is_internal = models.BooleanField(default=False) + version = models.CharField(default="v1", max_length=50) def __str__(self): return f"{self.workspace.slug} {self.url}" diff --git a/apps/api/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py index d3470d531..80a3e3e3e 100644 --- a/apps/api/plane/db/models/workspace.py +++ b/apps/api/plane/db/models/workspace.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import pytz from typing import Optional, Any @@ -204,6 +208,9 @@ class WorkspaceMember(BaseModel): default_props = models.JSONField(default=get_default_props) issue_props = models.JSONField(default=get_issue_props) is_active = models.BooleanField(default=True) + getting_started_checklist = models.JSONField(default=dict) + tips = models.JSONField(default=dict) + explored_features = models.JSONField(default=dict) class Meta: unique_together = ["workspace", "member", "deleted_at"] diff --git a/apps/api/plane/license/__init__.py b/apps/api/plane/license/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/license/__init__.py +++ b/apps/api/plane/license/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/license/api/__init__.py b/apps/api/plane/license/api/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/license/api/__init__.py +++ b/apps/api/plane/license/api/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/license/api/permissions/__init__.py b/apps/api/plane/license/api/permissions/__init__.py index d5bedc4c0..8878e2aaf 100644 --- a/apps/api/plane/license/api/permissions/__init__.py +++ b/apps/api/plane/license/api/permissions/__init__.py @@ -1 +1,5 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .instance import InstanceAdminPermission diff --git a/apps/api/plane/license/api/permissions/instance.py b/apps/api/plane/license/api/permissions/instance.py index a430b688b..819757375 100644 --- a/apps/api/plane/license/api/permissions/instance.py +++ b/apps/api/plane/license/api/permissions/instance.py @@ -1,3 +1,7 @@ +# 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.permissions import BasePermission diff --git a/apps/api/plane/license/api/serializers/__init__.py b/apps/api/plane/license/api/serializers/__init__.py index 6e0a5941c..b4a39adce 100644 --- a/apps/api/plane/license/api/serializers/__init__.py +++ b/apps/api/plane/license/api/serializers/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .instance import InstanceSerializer from .configuration import InstanceConfigurationSerializer diff --git a/apps/api/plane/license/api/serializers/admin.py b/apps/api/plane/license/api/serializers/admin.py index 4df6901ca..ebca0e562 100644 --- a/apps/api/plane/license/api/serializers/admin.py +++ b/apps/api/plane/license/api/serializers/admin.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from .base import BaseSerializer from plane.db.models import User diff --git a/apps/api/plane/license/api/serializers/base.py b/apps/api/plane/license/api/serializers/base.py index 0c6bba468..63c173e6d 100644 --- a/apps/api/plane/license/api/serializers/base.py +++ b/apps/api/plane/license/api/serializers/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from rest_framework import serializers diff --git a/apps/api/plane/license/api/serializers/configuration.py b/apps/api/plane/license/api/serializers/configuration.py index 1766f2113..21abc7013 100644 --- a/apps/api/plane/license/api/serializers/configuration.py +++ b/apps/api/plane/license/api/serializers/configuration.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .base import BaseSerializer from plane.license.models import InstanceConfiguration from plane.license.utils.encryption import decrypt_data diff --git a/apps/api/plane/license/api/serializers/instance.py b/apps/api/plane/license/api/serializers/instance.py index c75c62e50..1598b3fb6 100644 --- a/apps/api/plane/license/api/serializers/instance.py +++ b/apps/api/plane/license/api/serializers/instance.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from plane.license.models import Instance from plane.app.serializers import BaseSerializer diff --git a/apps/api/plane/license/api/serializers/user.py b/apps/api/plane/license/api/serializers/user.py index c53b4a484..b5e35ac72 100644 --- a/apps/api/plane/license/api/serializers/user.py +++ b/apps/api/plane/license/api/serializers/user.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .base import BaseSerializer from plane.db.models import User diff --git a/apps/api/plane/license/api/serializers/workspace.py b/apps/api/plane/license/api/serializers/workspace.py index 75dd938e4..d12473e20 100644 --- a/apps/api/plane/license/api/serializers/workspace.py +++ b/apps/api/plane/license/api/serializers/workspace.py @@ -1,3 +1,7 @@ +# 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 import serializers diff --git a/apps/api/plane/license/api/views/__init__.py b/apps/api/plane/license/api/views/__init__.py index 7f30d53fe..e25276495 100644 --- a/apps/api/plane/license/api/views/__init__.py +++ b/apps/api/plane/license/api/views/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .instance import InstanceEndpoint, SignUpScreenVisitedEndpoint diff --git a/apps/api/plane/license/api/views/admin.py b/apps/api/plane/license/api/views/admin.py index 5b70beab9..6217cc87f 100644 --- a/apps/api/plane/license/api/views/admin.py +++ b/apps/api/plane/license/api/views/admin.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports from urllib.parse import urlencode, urljoin import uuid @@ -134,8 +138,10 @@ class InstanceAdminSignUpEndpoint(View): }, ) url = urljoin( - base_host(request=request, is_admin=True, ), - + base_host( + request=request, + is_admin=True, + ), "?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -185,8 +191,8 @@ class InstanceAdminSignUpEndpoint(View): results = zxcvbn(password) if results["score"] < 3: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_PASSWORD"], - error_message="INVALID_ADMIN_PASSWORD", + error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"], + error_message="PASSWORD_TOO_WEAK", payload={ "email": email, "first_name": first_name, diff --git a/apps/api/plane/license/api/views/base.py b/apps/api/plane/license/api/views/base.py index d209bd6bf..8d0d39ac3 100644 --- a/apps/api/plane/license/api/views/base.py +++ b/apps/api/plane/license/api/views/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import zoneinfo from django.conf import settings diff --git a/apps/api/plane/license/api/views/configuration.py b/apps/api/plane/license/api/views/configuration.py index 8bb953565..bb9a9e00e 100644 --- a/apps/api/plane/license/api/views/configuration.py +++ b/apps/api/plane/license/api/views/configuration.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports from smtplib import ( SMTPAuthenticationError, diff --git a/apps/api/plane/license/api/views/instance.py b/apps/api/plane/license/api/views/instance.py index fed0c5e17..a0d52d491 100644 --- a/apps/api/plane/license/api/views/instance.py +++ b/apps/api/plane/license/api/views/instance.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os diff --git a/apps/api/plane/license/api/views/workspace.py b/apps/api/plane/license/api/views/workspace.py index 5d1a2f24b..966b3b3e8 100644 --- a/apps/api/plane/license/api/views/workspace.py +++ b/apps/api/plane/license/api/views/workspace.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/license/apps.py b/apps/api/plane/license/apps.py index 400e98155..0cd4aba3b 100644 --- a/apps/api/plane/license/apps.py +++ b/apps/api/plane/license/apps.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.apps import AppConfig diff --git a/apps/api/plane/license/bgtasks/__init__.py b/apps/api/plane/license/bgtasks/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/license/bgtasks/__init__.py +++ b/apps/api/plane/license/bgtasks/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/license/bgtasks/tracer.py b/apps/api/plane/license/bgtasks/tracer.py index 055c45d6c..f7c04b2a4 100644 --- a/apps/api/plane/license/bgtasks/tracer.py +++ b/apps/api/plane/license/bgtasks/tracer.py @@ -1,3 +1,7 @@ +# 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 celery import shared_task from opentelemetry import trace diff --git a/apps/api/plane/license/management/__init__.py b/apps/api/plane/license/management/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/license/management/__init__.py +++ b/apps/api/plane/license/management/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/license/management/commands/__init__.py b/apps/api/plane/license/management/commands/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/license/management/commands/__init__.py +++ b/apps/api/plane/license/management/commands/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/license/management/commands/configure_instance.py b/apps/api/plane/license/management/commands/configure_instance.py index b3e84dd82..43026a455 100644 --- a/apps/api/plane/license/management/commands/configure_instance.py +++ b/apps/api/plane/license/management/commands/configure_instance.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os diff --git a/apps/api/plane/license/management/commands/register_instance.py b/apps/api/plane/license/management/commands/register_instance.py index 6717cafd1..5ad6f7d20 100644 --- a/apps/api/plane/license/management/commands/register_instance.py +++ b/apps/api/plane/license/management/commands/register_instance.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json import secrets diff --git a/apps/api/plane/license/models/__init__.py b/apps/api/plane/license/models/__init__.py index d49524024..b1a84d846 100644 --- a/apps/api/plane/license/models/__init__.py +++ b/apps/api/plane/license/models/__init__.py @@ -1 +1,5 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .instance import Instance, InstanceAdmin, InstanceConfiguration, InstanceEdition diff --git a/apps/api/plane/license/models/instance.py b/apps/api/plane/license/models/instance.py index 1767d8c22..ff9ebc6b4 100644 --- a/apps/api/plane/license/models/instance.py +++ b/apps/api/plane/license/models/instance.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports from enum import Enum diff --git a/apps/api/plane/license/urls.py b/apps/api/plane/license/urls.py index 4d306924e..844a9e181 100644 --- a/apps/api/plane/license/urls.py +++ b/apps/api/plane/license/urls.py @@ -1,3 +1,7 @@ +# 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.license.api.views import ( diff --git a/apps/api/plane/license/utils/__init__.py b/apps/api/plane/license/utils/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/license/utils/__init__.py +++ b/apps/api/plane/license/utils/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/license/utils/encryption.py b/apps/api/plane/license/utils/encryption.py index d56766d1e..8f43167c1 100644 --- a/apps/api/plane/license/utils/encryption.py +++ b/apps/api/plane/license/utils/encryption.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import base64 import hashlib from django.conf import settings diff --git a/apps/api/plane/license/utils/instance_value.py b/apps/api/plane/license/utils/instance_value.py index 8901bc814..279eb2177 100644 --- a/apps/api/plane/license/utils/instance_value.py +++ b/apps/api/plane/license/utils/instance_value.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os diff --git a/apps/api/plane/middleware/__init__.py b/apps/api/plane/middleware/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/middleware/__init__.py +++ b/apps/api/plane/middleware/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/middleware/apps.py b/apps/api/plane/middleware/apps.py index 9deac8091..2037b6aa0 100644 --- a/apps/api/plane/middleware/apps.py +++ b/apps/api/plane/middleware/apps.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.apps import AppConfig diff --git a/apps/api/plane/middleware/db_routing.py b/apps/api/plane/middleware/db_routing.py index 68b5c4491..7aa045a69 100644 --- a/apps/api/plane/middleware/db_routing.py +++ b/apps/api/plane/middleware/db_routing.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ Database routing middleware for read replica selection. This middleware determines whether database queries should be routed to diff --git a/apps/api/plane/middleware/logger.py b/apps/api/plane/middleware/logger.py index d513ee3e3..b8cf6f9c0 100644 --- a/apps/api/plane/middleware/logger.py +++ b/apps/api/plane/middleware/logger.py @@ -1,16 +1,22 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import logging import time # Django imports from django.http import HttpRequest +from django.utils import timezone # Third party imports from rest_framework.request import Request # Module imports from plane.utils.ip_address import get_client_ip -from plane.db.models import APIActivityLog +from plane.utils.exception_logger import log_exception +from plane.bgtasks.logger_task import process_logs api_logger = logging.getLogger("plane.api.request") @@ -70,6 +76,10 @@ class RequestLoggerMiddleware: class APITokenLogMiddleware: + """ + Middleware to log External API requests to MongoDB or PostgreSQL. + """ + def __init__(self, get_response): self.get_response = get_response @@ -104,24 +114,41 @@ class APITokenLogMiddleware: def process_request(self, request, response, request_body): api_key_header = "X-Api-Key" api_key = request.headers.get(api_key_header) - # If the API key is present, log the request - if api_key: - try: - APIActivityLog.objects.create( - token_identifier=api_key, - path=request.path, - method=request.method, - query_params=request.META.get("QUERY_STRING", ""), - headers=str(request.headers), - body=(self._safe_decode_body(request_body) if request_body else None), - response_body=(self._safe_decode_body(response.content) if response.content else None), - response_code=response.status_code, - ip_address=get_client_ip(request=request), - user_agent=request.META.get("HTTP_USER_AGENT", None), - ) - except Exception as e: - api_logger.exception(e) - # If the token does not exist, you can decide whether to log this as an invalid attempt + # If the API key is not present, return + if not api_key: + return + + try: + log_data = { + "token_identifier": api_key, + "path": request.path, + "method": request.method, + "query_params": request.META.get("QUERY_STRING", ""), + "headers": str(request.headers), + "body": self._safe_decode_body(request_body) if request_body else None, + "response_body": self._safe_decode_body(response.content) if response.content else None, + "response_code": response.status_code, + "ip_address": get_client_ip(request=request), + "user_agent": request.META.get("HTTP_USER_AGENT", None), + } + user_id = ( + str(request.user.id) + if getattr(request, "user") and getattr(request.user, "is_authenticated", False) + else None + ) + # Additional fields for MongoDB + mongo_log = { + **log_data, + "created_at": timezone.now(), + "updated_at": timezone.now(), + "created_by": user_id, + "updated_by": user_id, + } + + process_logs.delay(log_data=log_data, mongo_log=mongo_log) + + except Exception as e: + log_exception(e) return None diff --git a/apps/api/plane/middleware/request_body_size.py b/apps/api/plane/middleware/request_body_size.py index 9807c5715..c4e014df6 100644 --- a/apps/api/plane/middleware/request_body_size.py +++ b/apps/api/plane/middleware/request_body_size.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.core.exceptions import RequestDataTooBig from django.http import JsonResponse diff --git a/apps/api/plane/seeds/data/issues.json b/apps/api/plane/seeds/data/issues.json index badd0e611..be966e723 100644 --- a/apps/api/plane/seeds/data/issues.json +++ b/apps/api/plane/seeds/data/issues.json @@ -3,7 +3,7 @@ "id": 1, "name": "Welcome to Plane 👋", "sequence_id": 1, - "description_html": "

    Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.

    Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.

    First thing to try

    1. Look in the Properties section below where it says State: Todo.

    2. Click on it and change it to Done from the dropdown. Alternatively, you can drag and drop the card to the Done column.

    ", + "description_html": "

    Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.

    Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.

    First thing to try

    1. Look in the Properties section below where it says State: Todo.

    2. Click on it and change it to Done from the dropdown. Alternatively, you can drag and drop the card to the Done column.

    ", "description_stripped": "Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.First thing to tryLook in the Properties section below where it says State: Todo.Click on it and change it to Done from the dropdown. Alternatively, you can drag and drop the card to the Done column.", "sort_order": 1000, "state_id": 4, @@ -17,7 +17,7 @@ "id": 2, "name": "1. Create Projects 🎯", "sequence_id": 2, - "description_html": "


    A Project in Plane is where all your work comes together. Think of it as a base that organizes your work items and everything else your team needs to get things done.

    Note: This tutorial is already set up as a Project, and these cards you're reading are work items within it!

    We're showing you how to create a new project just so you'll know exactly what to do when you're ready to start your own real one.

    1. Look over at the left sidebar and find where it says Projects.

    2. Hover your mouse there and you'll see a little + icon pop up - go ahead and click it!

    3. A modal opens where you can give your project a name and other details.

    4. Notice the Access type options? Public means anyone (except Guest users) can see and join it, while Private keeps it just for those you invite.

      Tip: You can also quickly create a new project by using the keyboard shortcut P from anywhere in Plane!

    ", + "description_html": "


    A Project in Plane is where all your work comes together. Think of it as a base that organizes your work items and everything else your team needs to get things done.

    Note: This tutorial is already set up as a Project, and these cards you're reading are work items within it!

    We're showing you how to create a new project just so you'll know exactly what to do when you're ready to start your own real one.

    1. Look over at the left sidebar and find where it says Projects.

    2. Hover your mouse there and you'll see a little + icon pop up - go ahead and click it!

    3. A modal opens where you can give your project a name and other details.

    4. Notice the Access type options? Public means anyone (except Guest users) can see and join it, while Private keeps it just for those you invite.

      Tip: You can also quickly create a new project by using the keyboard shortcut P from anywhere in Plane!

    ", "sort_order": 2000, "state_id": 2, "labels": [2], @@ -30,7 +30,7 @@ "id": 3, "name": "2. Invite your team 🤜🤛", "sequence_id": 3, - "description_html": "

    Let's get your teammates on board!

    First, you'll need to invite them to your workspace before they can join specific projects:

    1. Click on your workspace name in the top-left corner, then select Settings from the dropdown.

    2. Head over to the Members tab - this is your user management hub. Click Add member on the top right.

    3. Enter your teammate's email address. Select a role for them (Admin, Member or Guest) that determines what they can do in the workspace.

    4. Your team member will get an email invite. Once they've joined your workspace, you can add them to specific projects.

    5. To do this, go to your project's Settings page.

    6. Find the Members section, select your teammate, and assign them a project role - this controls what they can do within just this project.


    That's it!

    To learn more about user management, see Manage users and roles.

    ", + "description_html": "

    Let's get your teammates on board!

    First, you'll need to invite them to your workspace before they can join specific projects:

    1. Click on your workspace name in the top-left corner, then select Settings from the dropdown.

    2. Head over to the Members tab - this is your user management hub. Click Add member on the top right.

    3. Enter your teammate's email address. Select a role for them (Admin, Member or Guest) that determines what they can do in the workspace.

    4. Your team member will get an email invite. Once they've joined your workspace, you can add them to specific projects.

    5. To do this, go to your project's Settings page.

    6. Find the Members section, select your teammate, and assign them a project role - this controls what they can do within just this project.


    That's it!

    To learn more about user management, see Manage users and roles.

    ", "description_stripped": "Let's get your teammates on board!First, you'll need to invite them to your workspace before they can join specific projects:Click on your workspace name in the top-left corner, then select Settings from the dropdown.Head over to the Members tab - this is your user management hub. Click Add member on the top right.Enter your teammate's email address. Select a role for them (Admin, Member or Guest) that determines what they can do in the workspace.Your team member will get an email invite. Once they've joined your workspace, you can add them to specific projects.To do this, go to your project's Settings page.Find the Members section, select your teammate, and assign them a project role - this controls what they can do within just this project.That's it!To learn more about user management, see Manage users and roles.", "sort_order": 3000, "state_id": 1, @@ -44,7 +44,7 @@ "id": 4, "name": "3. Create and assign Work Items ✏️", "sequence_id": 4, - "description_html": "

    A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.

    Ready to add something to your project's to-do list? Here's how:

    1. Click the Add work item button in the top-right corner of the Work Items page.

    2. Give your task a clear title and add any details in the description.

    3. Set up the essentials:

      • Assign it to a team member (or yourself!)

      • Choose a priority level

      • Add start and due dates if there's a timeline

    Tip: Save time by using the keyboard shortcut C from anywhere in your project to quickly create a new work item!

    Want to dive deeper into all the things you can do with work items? Check out our documentation.

    ", + "description_html": "

    A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.

    Ready to add something to your project's to-do list? Here's how:

    1. Click the Add work item button in the top-right corner of the Work Items page.

    2. Give your task a clear title and add any details in the description.

    3. Set up the essentials:

      • Assign it to a team member (or yourself!)

      • Choose a priority level

      • Add start and due dates if there's a timeline

    Tip: Save time by using the keyboard shortcut C from anywhere in your project to quickly create a new work item!

    Want to dive deeper into all the things you can do with work items? Check out our documentation.

    ", "description_stripped": "A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.Ready to add something to your project's to-do list? Here's how:Click the Add work item button in the top-right corner of the Work Items page.Give your task a clear title and add any details in the description.Set up the essentials:Assign it to a team member (or yourself!)Choose a priority levelAdd start and due dates if there's a timelineTip: Save time by using the keyboard shortcut C from anywhere in your project to quickly create a new work item!Want to dive deeper into all the things you can do with work items? Check out our documentation.", "sort_order": 4000, "state_id": 3, @@ -58,7 +58,7 @@ "id": 5, "name": "4. Visualize your work 🔮", "sequence_id": 5, - "description_html": "

    Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!

    Switch between layouts

    1. Look at the top toolbar in your project. You'll see several layout icons.

    2. Click any of these icons to instantly switch between layouts.

    Tip: Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See Layouts for more info.

    Filter and display options

    Need to focus on specific work?

    1. Click the Filters dropdown in the toolbar. Select criteria and choose which items to show.

    2. Click the Display dropdown to tailor how the information appears in your layout

    3. Created the perfect setup? Save it for later by clicking the the Save View button.

    4. Access saved views anytime from the Views section in your sidebar.

    ", + "description_html": "

    Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!

    Switch between layouts

    1. Look at the top toolbar in your project. You'll see several layout icons.

    2. Click any of these icons to instantly switch between layouts.

    Filter and display options

    Need to focus on specific work?

    1. Click the Filters dropdown in the toolbar. Select criteria and choose which items to show.

    2. Click the Display dropdown to tailor how the information appears in your layout

    3. Created the perfect setup? Save it for later by clicking the the Save View button.

    4. Access saved views anytime from the Views section in your sidebar.

    ", "description_stripped": "Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!Switch between layoutsLook at the top toolbar in your project. You'll see several layout icons.Click any of these icons to instantly switch between layouts.Tip: Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See Layouts for more info.Filter and display optionsNeed to focus on specific work?Click the Filters dropdown in the toolbar. Select criteria and choose which items to show.Click the Display dropdown to tailor how the information appears in your layoutCreated the perfect setup? Save it for later by clicking the the Save View button.Access saved views anytime from the Views section in your sidebar.", "sort_order": 5000, "state_id": 3, @@ -72,7 +72,7 @@ "id": 6, "name": "5. Use Cycles to time box tasks 🗓️", "sequence_id": 6, - "description_html": "

    A Cycle in Plane is like a sprint - a dedicated timeframe where your team focuses on completing specific work items. It helps you break down your project into manageable chunks with clear start and end dates so everyone knows what to work on and when it needs to be done.

    Setup Cycles

    1. Go to the Cycles section in your project (you can find it in the left sidebar)

    2. Click the Add cycle button in the top-right corner

    3. Enter details and set the start and end dates for your cycle.

    4. Click Create cycle and you're ready to go!

    5. Add existing work items to the Cycle or create new ones.

    Tip: To create a new Cycle quickly, just press Q from anywhere in your project!

    Want to learn more?

    • Starting and stopping cycles

    • Transferring work items between cycles

    • Tracking progress with charts

    Check out our detailed documentation for everything you need to know!

    ", + "description_html": "

    A Cycle in Plane is like a sprint - a dedicated timeframe where your team focuses on completing specific work items. It helps you break down your project into manageable chunks with clear start and end dates so everyone knows what to work on and when it needs to be done.

    Setup Cycles

    1. Go to the Cycles section in your project (you can find it in the left sidebar)

    2. Click the Add cycle button in the top-right corner

    3. Enter details and set the start and end dates for your cycle.

    4. Click Create cycle and you're ready to go!

    5. Add existing work items to the Cycle or create new ones.

    Tip: To create a new Cycle quickly, just press Q from anywhere in your project!

    Want to learn more?

    • Starting and stopping cycles

    • Transferring work items between cycles

    • Tracking progress with charts

    Check out our detailed documentation for everything you need to know!

    ", "description_stripped": "A Cycle in Plane is like a sprint - a dedicated timeframe where your team focuses on completing specific work items. It helps you break down your project into manageable chunks with clear start and end dates so everyone knows what to work on and when it needs to be done.Setup CyclesGo to the Cycles section in your project (you can find it in the left sidebar)Click the Add cycle button in the top-right cornerEnter details and set the start and end dates for your cycle.Click Create cycle and you're ready to go!Add existing work items to the Cycle or create new ones.Tip: To create a new Cycle quickly, just press Q from anywhere in your project!Want to learn more?Starting and stopping cyclesTransferring work items between cyclesTracking progress with chartsCheck out our detailed documentation for everything you need to know!", "sort_order": 6000, "state_id": 1, @@ -86,7 +86,7 @@ "id": 7, "name": "6. Customize your settings ⚙️", "sequence_id": 7, - "description_html": "

    Now that you're getting familiar with Plane, let's explore how you can customize settings to make it work just right for you and your team!

    Workspace settings

    Remember those workspace settings we mentioned when inviting team members? There's a lot more you can do there:

    • Invite and manage workspace members

    • Upgrade plans and manage billing

    • Import data from other tools

    • Export your data

    • Manage integrations

    Project Settings

    Each project has its own settings where you can:

    • Change project details and visibility

    • Invite specific members to just this project

    • Customize your workflow States (like adding a \"Testing\" state)

    • Create and organize Labels

    • Enable or disable features you need (or don't need)

    Your Profile Settings

    You can also customize your own personal experience! Click on your profile icon in the top-right corner to find:

    • Profile settings (update your name, photo, etc.)

    • Choose your timezone and preferred language for the interface

    • Email notification preferences (what you want to be alerted about)

    • Appearance settings (light/dark mode)

    Taking a few minutes to set things up just the way you like will make your everyday Plane experience much smoother!

    Note: Some settings are only available to workspace or project admins. If you don't see certain options, you might need admin access.

    ", + "description_html": "

    Now that you're getting familiar with Plane, let's explore how you can customize settings to make it work just right for you and your team!

    Workspace settings

    Remember those workspace settings we mentioned when inviting team members? There's a lot more you can do there:

    • Invite and manage workspace members

    • Upgrade plans and manage billing

    • Import data from other tools

    • Export your data

    • Manage integrations

    Project Settings

    Each project has its own settings where you can:

    • Change project details and visibility

    • Invite specific members to just this project

    • Customize your workflow States (like adding a \"Testing\" state)

    • Create and organize Labels

    • Enable or disable features you need (or don't need)

    Your Profile Settings

    You can also customize your own personal experience! Click on your profile icon in the top-right corner to find:

    • Profile settings (update your name, photo, etc.)

    • Choose your timezone and preferred language for the interface

    • Email notification preferences (what you want to be alerted about)

    • Appearance settings (light/dark mode)

    Taking a few minutes to set things up just the way you like will make your everyday Plane experience much smoother!

    Note: Some settings are only available to workspace or project admins. If you don't see certain options, you might need admin access.

    ", "description_stripped": "Now that you're getting familiar with Plane, let's explore how you can customize settings to make it work just right for you and your team!Workspace settingsRemember those workspace settings we mentioned when inviting team members? There's a lot more you can do there:Invite and manage workspace membersUpgrade plans and manage billingImport data from other toolsExport your dataManage integrationsProject SettingsEach project has its own settings where you can:Change project details and visibilityInvite specific members to just this projectCustomize your workflow States (like adding a \"Testing\" state)Create and organize LabelsEnable or disable features you need (or don't need)Your Profile SettingsYou can also customize your own personal experience! Click on your profile icon in the top-right corner to find:Profile settings (update your name, photo, etc.)Choose your timezone and preferred language for the interfaceEmail notification preferences (what you want to be alerted about)Appearance settings (light/dark mode)Taking a few minutes to set things up just the way you like will make your everyday Plane experience much smoother!Note: Some settings are only available to workspace or project admins. If you don't see certain options, you might need admin access.", "sort_order": 7000, "state_id": 1, diff --git a/apps/api/plane/seeds/data/pages.json b/apps/api/plane/seeds/data/pages.json index d719220bf..00c5c91ef 100644 --- a/apps/api/plane/seeds/data/pages.json +++ b/apps/api/plane/seeds/data/pages.json @@ -1,30 +1,30 @@ [ - { - "id": 1, - "name": "Project Design Spec", - "project_id": 1, - "description_html": "

    Welcome to your Project Pages — the documentation hub for this specific project.
    Each project in Plane can have its own Wiki space where you track plans, specs, updates, and learnings — all connected to your issues and modules.

    🧭 Project Summary

    Field

    Details

    Project Name

    Add your project name

    Owner

    Add project owner(s)

    Status

    🟢 Active / 🟡 In Progress / 🔴 Blocked

    Start Date

    Target Release

    Linked Modules

    Engineering, Security

    Cycle(s)

    Cycle 1, Cycle 2

    🧩 Use tables to summarize key project metadata or links.

    🎯 Goals & Objectives

    🎯 Primary Goals

    • Deliver MVP with all core features

    • Validate feature adoption with early users

    • Prepare launch plan for v1 release

    Success Metrics

    Metric

    Target

    Owner

    User adoption

    100 active users

    Growth

    Performance

    < 200ms latency

    Backend

    Design feedback

    ≥ 8/10 average rating

    Design

    📈 Define measurable outcomes and track progress alongside issues.

    🧩 Scope & Deliverables

    Deliverable

    Owner

    Status

    Authentication flow

    Backend

    Done

    Issue board UI

    Frontend

    🏗 In Progress

    API integration

    Backend

    Pending

    Documentation

    PM

    📝 Drafting

    🧩 Use tables or checklists to track scope and ownership.

    🧱 Architecture or System Design

    Use this section for technical deep dives or diagrams.

    Frontend → GraphQL → Backend → PostgreSQL\nRedis for caching | RabbitMQ for background jobs

    ", - "description": "{\"type\": \"doc\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Welcome to your \", \"type\": \"text\"}, {\"text\": \"Project Pages\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — the documentation hub for this specific project.\", \"type\": \"text\"}, {\"type\": \"hardBreak\"}, {\"text\": \"Each project in Plane can have its own Wiki space where you track \", \"type\": \"text\"}, {\"text\": \"plans, specs, updates, and learnings\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — all connected to your issues and modules.\", \"type\": \"text\"}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"compass\"}}, {\"text\": \" Project Summary\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Field\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Details\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Project Name\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"pencil\"}}, {\"text\": \" Add your project name\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Add project owner(s)\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Status\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"green_circle\"}}, {\"text\": \" Active / \", \"type\": \"text\"}, {\"type\": \"emoji\", \"attrs\": {\"name\": \"yellow_circle\"}}, {\"text\": \" In Progress / \", \"type\": \"text\"}, {\"type\": \"emoji\", \"attrs\": {\"name\": \"red_circle\"}}, {\"text\": \" Blocked\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Start Date\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"—\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Target Release\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"—\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Linked Modules\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Engineering, Security\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Cycle(s)\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Cycle 1, Cycle 2\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Use tables to summarize key project metadata or links.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bullseye\"}}, {\"text\": \" Goals & Objectives\", \"type\": \"text\"}]}, {\"type\": \"heading\", \"attrs\": {\"level\": 3, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bullseye\"}}, {\"text\": \" Primary Goals\", \"type\": \"text\"}]}, {\"type\": \"bulletList\", \"content\": [{\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Deliver MVP with all core features\", \"type\": \"text\"}]}]}, {\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Validate feature adoption with early users\", \"type\": \"text\"}]}]}, {\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Prepare launch plan for v1 release\", \"type\": \"text\"}]}]}]}, {\"type\": \"heading\", \"attrs\": {\"level\": 3, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"gear\"}}, {\"text\": \" Success Metrics\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Metric\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Target\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"User adoption\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"100 active users\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Growth\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Performance\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"< 200ms latency\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Design feedback\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"≥ 8/10 average rating\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Design\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"chart_increasing\"}}, {\"text\": \" Define measurable outcomes and track progress alongside issues.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Scope & Deliverables\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Deliverable\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Status\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Authentication flow\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"check_mark_button\"}}, {\"text\": \" Done\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Issue board UI\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Frontend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"building_construction\"}}, {\"text\": \" In Progress\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"API integration\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"hourglass_flowing_sand\"}}, {\"text\": \" Pending\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Documentation\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"PM\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"memo\"}}, {\"text\": \" Drafting\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Use tables or checklists to track scope and ownership.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bricks\"}}, {\"text\": \" Architecture or System Design\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Use this section for \", \"type\": \"text\"}, {\"text\": \"technical deep dives\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" or diagrams.\", \"type\": \"text\"}]}, {\"type\": \"codeBlock\", \"attrs\": {\"language\": \"bash\"}, \"content\": [{\"text\": \"Frontend → GraphQL → Backend → PostgreSQL\\nRedis for caching | RabbitMQ for background jobs\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}]}", - "description_stripped": "Welcome to your Project Pages — the documentation hub for this specific project.Each project in Plane can have its own Wiki space where you track plans, specs, updates, and learnings — all connected to your issues and modules.🧭 Project SummaryFieldDetailsProject Name✏ Add your project nameOwnerAdd project owner(s)Status🟢 Active / 🟡 In Progress / 🔴 BlockedStart Date—Target Release—Linked ModulesEngineering, SecurityCycle(s)Cycle 1, Cycle 2🧩 Use tables to summarize key project metadata or links.🎯 Goals & Objectives🎯 Primary GoalsDeliver MVP with all core featuresValidate feature adoption with early usersPrepare launch plan for v1 release⚙ Success MetricsMetricTargetOwnerUser adoption100 active usersGrowthPerformance< 200ms latencyBackendDesign feedback≥ 8/10 average ratingDesign📈 Define measurable outcomes and track progress alongside issues.🧩 Scope & DeliverablesDeliverableOwnerStatusAuthentication flowBackend✅ DoneIssue board UIFrontend🏗 In ProgressAPI integrationBackend⏳ PendingDocumentationPM📝 Drafting🧩 Use tables or checklists to track scope and ownership.🧱 Architecture or System DesignUse this section for technical deep dives or diagrams.Frontend → GraphQL → Backend → PostgreSQL\nRedis for caching | RabbitMQ for background jobs", - "type": "PROJECT", - "access": 0, - "logo_props": { - "emoji": { - "url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f680.png", - "value": "128640" - }, - "in_use": "emoji" - } - }, - { - "id": 2, - "name": "Project Draft proposal", - "project_id": 1, - "description": "{\"type\": \"doc\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"This is your \", \"type\": \"text\"}, {\"text\": \"Project Draft area\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"It’s visible only to you (and collaborators you explicitly share with).\", \"type\": \"text\"}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"writing_hand\"}}, {\"text\": \" Current Work in Progress\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"speech_balloon\"}}, {\"text\": \" Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet.\", \"type\": \"text\"}]}]}, {\"type\": \"taskList\", \"content\": [{\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Outline project summary and goals\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Draft new feature spec\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Review dependency list\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Collect team feedback for next iteration\", \"type\": \"text\"}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"check_mark_button\"}}, {\"text\": \" Tip: Turn these items into actionable issues when finalized.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bricks\"}}, {\"text\": \" Prototype Commands (if technical)\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"You can also use \", \"type\": \"text\"}, {\"text\": \"code blocks\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" to store snippets, scripts, or notes:\", \"type\": \"text\"}]}, {\"type\": \"codeBlock\", \"attrs\": {\"language\": \"bash\"}, \"content\": [{\"text\": \"# Rebuild Docker containers\\ndocker compose build backend frontend\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}]}", - "description_html": "

    This is your Project Draft area — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.

    It’s visible only to you (and collaborators you explicitly share with).

    Current Work in Progress

    💬 Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet.

    • Outline project summary and goals

    • Draft new feature spec

    • Review dependency list

    • Collect team feedback for next iteration

    Tip: Turn these items into actionable issues when finalized.

    🧱 Prototype Commands (if technical)

    You can also use code blocks to store snippets, scripts, or notes:

    # Rebuild Docker containers\ndocker compose build backend frontend

    ", - "description_stripped": "This is your Project Draft area — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.It’s visible only to you (and collaborators you explicitly share with).✍ Current Work in Progress💬 Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet. Outline project summary and goals Draft new feature spec Review dependency list Collect team feedback for next iteration✅ Tip: Turn these items into actionable issues when finalized.🧱 Prototype Commands (if technical)You can also use code blocks to store snippets, scripts, or notes:# Rebuild Docker containers\ndocker compose build backend frontend", - "type": "PROJECT", - "access": 1, - "logo_props": "{\"emoji\": {\"url\": \"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f9f1.png\", \"value\": \"129521\"}, \"in_use\": \"emoji\"}" + { + "id": 1, + "name": "Project Design Spec", + "project_id": 1, + "description_html": "

    Welcome to your Project Pages — the documentation hub for this specific project.
    Each project in Plane can have its own Wiki space where you track plans, specs, updates, and learnings — all connected to your issues and modules.

    🧭 Project Summary

    Field

    Details

    Project Name

    Add your project name

    Owner

    Add project owner(s)

    Status

    🟢 Active / 🟡 In Progress / 🔴 Blocked

    Start Date

    Target Release

    Linked Modules

    Engineering, Security

    Cycle(s)

    Cycle 1, Cycle 2

    🧩 Use tables to summarize key project metadata or links.

    🎯 Goals & Objectives

    🎯 Primary Goals

    • Deliver MVP with all core features

    • Validate feature adoption with early users

    • Prepare launch plan for v1 release

    Success Metrics

    Metric

    Target

    Owner

    User adoption

    100 active users

    Growth

    Performance

    < 200ms latency

    Backend

    Design feedback

    ≥ 8/10 average rating

    Design

    📈 Define measurable outcomes and track progress alongside issues.

    🧩 Scope & Deliverables

    Deliverable

    Owner

    Status

    Authentication flow

    Backend

    Done

    Issue board UI

    Frontend

    🏗 In Progress

    API integration

    Backend

    Pending

    Documentation

    PM

    📝 Drafting

    🧩 Use tables or checklists to track scope and ownership.

    🧱 Architecture or System Design

    Use this section for technical deep dives or diagrams.

    Frontend → GraphQL → Backend → PostgreSQL\nRedis for caching | RabbitMQ for background jobs

    ", + "description": "{\"type\": \"doc\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Welcome to your \", \"type\": \"text\"}, {\"text\": \"Project Pages\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — the documentation hub for this specific project.\", \"type\": \"text\"}, {\"type\": \"hardBreak\"}, {\"text\": \"Each project in Plane can have its own Wiki space where you track \", \"type\": \"text\"}, {\"text\": \"plans, specs, updates, and learnings\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — all connected to your issues and modules.\", \"type\": \"text\"}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"compass\"}}, {\"text\": \" Project Summary\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Field\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Details\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Project Name\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"pencil\"}}, {\"text\": \" Add your project name\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Add project owner(s)\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Status\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"green_circle\"}}, {\"text\": \" Active / \", \"type\": \"text\"}, {\"type\": \"emoji\", \"attrs\": {\"name\": \"yellow_circle\"}}, {\"text\": \" In Progress / \", \"type\": \"text\"}, {\"type\": \"emoji\", \"attrs\": {\"name\": \"red_circle\"}}, {\"text\": \" Blocked\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Start Date\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"—\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Target Release\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"—\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Linked Modules\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Engineering, Security\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Cycle(s)\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Cycle 1, Cycle 2\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Use tables to summarize key project metadata or links.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bullseye\"}}, {\"text\": \" Goals & Objectives\", \"type\": \"text\"}]}, {\"type\": \"heading\", \"attrs\": {\"level\": 3, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bullseye\"}}, {\"text\": \" Primary Goals\", \"type\": \"text\"}]}, {\"type\": \"bulletList\", \"content\": [{\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Deliver MVP with all core features\", \"type\": \"text\"}]}]}, {\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Validate feature adoption with early users\", \"type\": \"text\"}]}]}, {\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Prepare launch plan for v1 release\", \"type\": \"text\"}]}]}]}, {\"type\": \"heading\", \"attrs\": {\"level\": 3, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"gear\"}}, {\"text\": \" Success Metrics\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Metric\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Target\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"User adoption\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"100 active users\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Growth\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Performance\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"< 200ms latency\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Design feedback\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"≥ 8/10 average rating\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Design\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"chart_increasing\"}}, {\"text\": \" Define measurable outcomes and track progress alongside issues.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Scope & Deliverables\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Deliverable\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Status\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Authentication flow\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"check_mark_button\"}}, {\"text\": \" Done\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Issue board UI\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Frontend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"building_construction\"}}, {\"text\": \" In Progress\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"API integration\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"hourglass_flowing_sand\"}}, {\"text\": \" Pending\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Documentation\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"PM\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"memo\"}}, {\"text\": \" Drafting\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Use tables or checklists to track scope and ownership.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bricks\"}}, {\"text\": \" Architecture or System Design\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Use this section for \", \"type\": \"text\"}, {\"text\": \"technical deep dives\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" or diagrams.\", \"type\": \"text\"}]}, {\"type\": \"codeBlock\", \"attrs\": {\"language\": \"bash\"}, \"content\": [{\"text\": \"Frontend → GraphQL → Backend → PostgreSQL\\nRedis for caching | RabbitMQ for background jobs\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}]}", + "description_stripped": "Welcome to your Project Pages — the documentation hub for this specific project.Each project in Plane can have its own Wiki space where you track plans, specs, updates, and learnings — all connected to your issues and modules.🧭 Project SummaryFieldDetailsProject Name✏ Add your project nameOwnerAdd project owner(s)Status🟢 Active / 🟡 In Progress / 🔴 BlockedStart Date—Target Release—Linked ModulesEngineering, SecurityCycle(s)Cycle 1, Cycle 2🧩 Use tables to summarize key project metadata or links.🎯 Goals & Objectives🎯 Primary GoalsDeliver MVP with all core featuresValidate feature adoption with early usersPrepare launch plan for v1 release⚙ Success MetricsMetricTargetOwnerUser adoption100 active usersGrowthPerformance< 200ms latencyBackendDesign feedback≥ 8/10 average ratingDesign📈 Define measurable outcomes and track progress alongside issues.🧩 Scope & DeliverablesDeliverableOwnerStatusAuthentication flowBackend✅ DoneIssue board UIFrontend🏗 In ProgressAPI integrationBackend⏳ PendingDocumentationPM📝 Drafting🧩 Use tables or checklists to track scope and ownership.🧱 Architecture or System DesignUse this section for technical deep dives or diagrams.Frontend → GraphQL → Backend → PostgreSQL\nRedis for caching | RabbitMQ for background jobs", + "type": "PROJECT", + "access": 0, + "logo_props": { + "emoji": { + "url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f680.png", + "value": "128640" + }, + "in_use": "emoji" } -] \ No newline at end of file + }, + { + "id": 2, + "name": "Project Draft proposal", + "project_id": 1, + "description": "{\"type\": \"doc\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"This is your \", \"type\": \"text\"}, {\"text\": \"Project Draft area\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"It’s visible only to you (and collaborators you explicitly share with).\", \"type\": \"text\"}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"writing_hand\"}}, {\"text\": \" Current Work in Progress\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"speech_balloon\"}}, {\"text\": \" Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet.\", \"type\": \"text\"}]}]}, {\"type\": \"taskList\", \"content\": [{\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Outline project summary and goals\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Draft new feature spec\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Review dependency list\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Collect team feedback for next iteration\", \"type\": \"text\"}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"check_mark_button\"}}, {\"text\": \" Tip: Turn these items into actionable issues when finalized.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bricks\"}}, {\"text\": \" Prototype Commands (if technical)\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"You can also use \", \"type\": \"text\"}, {\"text\": \"code blocks\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" to store snippets, scripts, or notes:\", \"type\": \"text\"}]}, {\"type\": \"codeBlock\", \"attrs\": {\"language\": \"bash\"}, \"content\": [{\"text\": \"# Rebuild Docker containers\\ndocker compose build backend frontend\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}]}", + "description_html": "

    This is your Project Draft area — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.

    It’s visible only to you (and collaborators you explicitly share with).

    Current Work in Progress

    💬 Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet.

    • Outline project summary and goals

    • Draft new feature spec

    • Review dependency list

    • Collect team feedback for next iteration

    Tip: Turn these items into actionable issues when finalized.

    🧱 Prototype Commands (if technical)

    You can also use code blocks to store snippets, scripts, or notes:

    # Rebuild Docker containers\ndocker compose build backend frontend

    ", + "description_stripped": "This is your Project Draft area — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.It’s visible only to you (and collaborators you explicitly share with).✍ Current Work in Progress💬 Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet. Outline project summary and goals Draft new feature spec Review dependency list Collect team feedback for next iteration✅ Tip: Turn these items into actionable issues when finalized.🧱 Prototype Commands (if technical)You can also use code blocks to store snippets, scripts, or notes:# Rebuild Docker containers\ndocker compose build backend frontend", + "type": "PROJECT", + "access": 1, + "logo_props": "{\"emoji\": {\"url\": \"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f9f1.png\", \"value\": \"129521\"}, \"in_use\": \"emoji\"}" + } +] diff --git a/apps/api/plane/settings/__init__.py b/apps/api/plane/settings/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/settings/__init__.py +++ b/apps/api/plane/settings/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index a9e9925c2..9d651bd1b 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """Global Settings""" # Python imports @@ -36,6 +40,7 @@ INSTALLED_APPS = [ "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", + "django.contrib.staticfiles", # Inhouse apps "plane.analytics", "plane.app", @@ -58,6 +63,7 @@ INSTALLED_APPS = [ MIDDLEWARE = [ "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", "plane.authentication.middleware.session.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -378,6 +384,7 @@ ATTACHMENT_MIME_TYPES = [ "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "text/plain", + "text/markdown", "application/rtf", "application/vnd.oasis.opendocument.spreadsheet", "application/vnd.oasis.opendocument.text", @@ -445,6 +452,8 @@ ATTACHMENT_MIME_TYPES = [ "application/x-sql", # Gzip "application/x-gzip", + # Markdown + "text/markdown", ] # Seed directory path diff --git a/apps/api/plane/settings/local.py b/apps/api/plane/settings/local.py index 15f05aa3d..dc4135bc1 100644 --- a/apps/api/plane/settings/local.py +++ b/apps/api/plane/settings/local.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """Development settings""" import os @@ -76,6 +80,11 @@ LOGGING = { "handlers": ["console"], "propagate": False, }, + "plane.authentication": { + "level": "INFO", + "handlers": ["console"], + "propagate": False, + }, "plane.migrations": { "level": "INFO", "handlers": ["console"], diff --git a/apps/api/plane/settings/mongo.py b/apps/api/plane/settings/mongo.py index 879d0c436..7855a52d5 100644 --- a/apps/api/plane/settings/mongo.py +++ b/apps/api/plane/settings/mongo.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.conf import settings import logging diff --git a/apps/api/plane/settings/openapi.py b/apps/api/plane/settings/openapi.py index b79daeecf..a1961a0c5 100644 --- a/apps/api/plane/settings/openapi.py +++ b/apps/api/plane/settings/openapi.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ OpenAPI/Swagger configuration for drf-spectacular. diff --git a/apps/api/plane/settings/production.py b/apps/api/plane/settings/production.py index 8df7ae906..7f3f90d65 100644 --- a/apps/api/plane/settings/production.py +++ b/apps/api/plane/settings/production.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """Production settings""" import os @@ -86,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"], diff --git a/apps/api/plane/settings/redis.py b/apps/api/plane/settings/redis.py index 628a3d8e6..6c7e613f0 100644 --- a/apps/api/plane/settings/redis.py +++ b/apps/api/plane/settings/redis.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import redis from django.conf import settings from urllib.parse import urlparse diff --git a/apps/api/plane/settings/storage.py b/apps/api/plane/settings/storage.py index 01afa6237..e4a978bd2 100644 --- a/apps/api/plane/settings/storage.py +++ b/apps/api/plane/settings/storage.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os import uuid @@ -187,3 +191,15 @@ class S3Storage(S3Boto3Storage): except ClientError as e: log_exception(e) return False + + def delete_files(self, object_names): + """Delete an S3 object""" + try: + self.s3_client.delete_objects( + Bucket=self.aws_storage_bucket_name, + Delete={"Objects": [{"Key": object_name} for object_name in object_names]}, + ) + return True + except ClientError as e: + log_exception(e) + return False diff --git a/apps/api/plane/settings/test.py b/apps/api/plane/settings/test.py index 6a75f7904..a8e431338 100644 --- a/apps/api/plane/settings/test.py +++ b/apps/api/plane/settings/test.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """Test Settings""" from .common import * # noqa diff --git a/apps/api/plane/space/__init__.py b/apps/api/plane/space/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/space/__init__.py +++ b/apps/api/plane/space/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/space/apps.py b/apps/api/plane/space/apps.py index 6f1e76c51..dd178e334 100644 --- a/apps/api/plane/space/apps.py +++ b/apps/api/plane/space/apps.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.apps import AppConfig diff --git a/apps/api/plane/space/serializer/__init__.py b/apps/api/plane/space/serializer/__init__.py index a3fe1029f..e571ac011 100644 --- a/apps/api/plane/space/serializer/__init__.py +++ b/apps/api/plane/space/serializer/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .user import UserLiteSerializer from .issue import LabelLiteSerializer, IssuePublicSerializer diff --git a/apps/api/plane/space/serializer/base.py b/apps/api/plane/space/serializer/base.py index 4b92b06fc..9f30a7a83 100644 --- a/apps/api/plane/space/serializer/base.py +++ b/apps/api/plane/space/serializer/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from rest_framework import serializers diff --git a/apps/api/plane/space/serializer/cycle.py b/apps/api/plane/space/serializer/cycle.py index afa760a59..617ac0842 100644 --- a/apps/api/plane/space/serializer/cycle.py +++ b/apps/api/plane/space/serializer/cycle.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from .base import BaseSerializer from plane.db.models import Cycle diff --git a/apps/api/plane/space/serializer/intake.py b/apps/api/plane/space/serializer/intake.py index 444c20d42..cf22cebbb 100644 --- a/apps/api/plane/space/serializer/intake.py +++ b/apps/api/plane/space/serializer/intake.py @@ -1,3 +1,7 @@ +# 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 import serializers diff --git a/apps/api/plane/space/serializer/issue.py b/apps/api/plane/space/serializer/issue.py index a89846cfc..51dd1f41d 100644 --- a/apps/api/plane/space/serializer/issue.py +++ b/apps/api/plane/space/serializer/issue.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.utils import timezone @@ -193,7 +197,7 @@ class IssueFlatSerializer(BaseSerializer): fields = [ "id", "name", - "description", + "description_json", "description_html", "priority", "start_date", diff --git a/apps/api/plane/space/serializer/module.py b/apps/api/plane/space/serializer/module.py index 53840f078..81ba93c13 100644 --- a/apps/api/plane/space/serializer/module.py +++ b/apps/api/plane/space/serializer/module.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from .base import BaseSerializer from plane.db.models import Module diff --git a/apps/api/plane/space/serializer/project.py b/apps/api/plane/space/serializer/project.py index f79eef686..62be19f4f 100644 --- a/apps/api/plane/space/serializer/project.py +++ b/apps/api/plane/space/serializer/project.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from .base import BaseSerializer from plane.db.models import Project diff --git a/apps/api/plane/space/serializer/state.py b/apps/api/plane/space/serializer/state.py index 184f48b40..410b408f0 100644 --- a/apps/api/plane/space/serializer/state.py +++ b/apps/api/plane/space/serializer/state.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from .base import BaseSerializer from plane.db.models import State diff --git a/apps/api/plane/space/serializer/user.py b/apps/api/plane/space/serializer/user.py index 9b707a343..4ecbad80e 100644 --- a/apps/api/plane/space/serializer/user.py +++ b/apps/api/plane/space/serializer/user.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from .base import BaseSerializer from plane.db.models import User diff --git a/apps/api/plane/space/serializer/workspace.py b/apps/api/plane/space/serializer/workspace.py index 4945af96a..c63dfe2a5 100644 --- a/apps/api/plane/space/serializer/workspace.py +++ b/apps/api/plane/space/serializer/workspace.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Module imports from .base import BaseSerializer from plane.db.models import Workspace diff --git a/apps/api/plane/space/urls/__init__.py b/apps/api/plane/space/urls/__init__.py index d9a1f6ec3..06d3a117a 100644 --- a/apps/api/plane/space/urls/__init__.py +++ b/apps/api/plane/space/urls/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .intake import urlpatterns as intake_urls from .issue import urlpatterns as issue_urls from .project import urlpatterns as project_urls diff --git a/apps/api/plane/space/urls/asset.py b/apps/api/plane/space/urls/asset.py index 2a5c30a22..050aeb4ab 100644 --- a/apps/api/plane/space/urls/asset.py +++ b/apps/api/plane/space/urls/asset.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.urls import path diff --git a/apps/api/plane/space/urls/intake.py b/apps/api/plane/space/urls/intake.py index 59fda12e2..470f7f7b7 100644 --- a/apps/api/plane/space/urls/intake.py +++ b/apps/api/plane/space/urls/intake.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/space/urls/issue.py b/apps/api/plane/space/urls/issue.py index bb63e6695..5ea7671c2 100644 --- a/apps/api/plane/space/urls/issue.py +++ b/apps/api/plane/space/urls/issue.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/space/urls/project.py b/apps/api/plane/space/urls/project.py index 068b8c5c1..1d58aba42 100644 --- a/apps/api/plane/space/urls/project.py +++ b/apps/api/plane/space/urls/project.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/space/utils/grouper.py b/apps/api/plane/space/utils/grouper.py index f8e2c50a4..e5f893bd5 100644 --- a/apps/api/plane/space/utils/grouper.py +++ b/apps/api/plane/space/utils/grouper.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField diff --git a/apps/api/plane/space/views/__init__.py b/apps/api/plane/space/views/__init__.py index 22acfd15b..f70d094de 100644 --- a/apps/api/plane/space/views/__init__.py +++ b/apps/api/plane/space/views/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .project import ( ProjectDeployBoardPublicSettingsEndpoint, WorkspaceProjectDeployBoardEndpoint, diff --git a/apps/api/plane/space/views/asset.py b/apps/api/plane/space/views/asset.py index faabd97ab..1749a8fd4 100644 --- a/apps/api/plane/space/views/asset.py +++ b/apps/api/plane/space/views/asset.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import uuid diff --git a/apps/api/plane/space/views/base.py b/apps/api/plane/space/views/base.py index 9be6a2e10..cf8cdbdc5 100644 --- a/apps/api/plane/space/views/base.py +++ b/apps/api/plane/space/views/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import zoneinfo from django.conf import settings diff --git a/apps/api/plane/space/views/cycle.py b/apps/api/plane/space/views/cycle.py index 505c17ba4..72bec3064 100644 --- a/apps/api/plane/space/views/cycle.py +++ b/apps/api/plane/space/views/cycle.py @@ -1,3 +1,7 @@ +# 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 import status from rest_framework.permissions import AllowAny diff --git a/apps/api/plane/space/views/intake.py b/apps/api/plane/space/views/intake.py index 7ea2dee91..4d9913193 100644 --- a/apps/api/plane/space/views/intake.py +++ b/apps/api/plane/space/views/intake.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json @@ -140,7 +144,7 @@ class IntakeIssuePublicViewSet(BaseViewSet): # create an issue issue = Issue.objects.create( name=request.data.get("issue", {}).get("name"), - description=request.data.get("issue", {}).get("description", {}), + description_json=request.data.get("issue", {}).get("description_json", {}), description_html=request.data.get("issue", {}).get("description_html", "

    "), priority=request.data.get("issue", {}).get("priority", "low"), project_id=project_deploy_board.project_id, @@ -201,7 +205,7 @@ class IntakeIssuePublicViewSet(BaseViewSet): issue_data = { "name": issue_data.get("name", issue.name), "description_html": issue_data.get("description_html", issue.description_html), - "description": issue_data.get("description", issue.description), + "description_json": issue_data.get("description_json", issue.description_json), } issue_serializer = IssueCreateSerializer( diff --git a/apps/api/plane/space/views/issue.py b/apps/api/plane/space/views/issue.py index 220fc1307..9e2187466 100644 --- a/apps/api/plane/space/views/issue.py +++ b/apps/api/plane/space/views/issue.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json @@ -744,7 +748,7 @@ class IssueRetrievePublicEndpoint(BaseAPIView): "name", "state_id", "sort_order", - "description", + "description_json", "description_html", "description_stripped", "description_binary", diff --git a/apps/api/plane/space/views/label.py b/apps/api/plane/space/views/label.py index 51ddb832e..f7cde57eb 100644 --- a/apps/api/plane/space/views/label.py +++ b/apps/api/plane/space/views/label.py @@ -1,3 +1,7 @@ +# 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 diff --git a/apps/api/plane/space/views/meta.py b/apps/api/plane/space/views/meta.py index be612db70..740bed19f 100644 --- a/apps/api/plane/space/views/meta.py +++ b/apps/api/plane/space/views/meta.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # third party from rest_framework.permissions import AllowAny from rest_framework import status diff --git a/apps/api/plane/space/views/module.py b/apps/api/plane/space/views/module.py index 7c4628f64..2df0166ac 100644 --- a/apps/api/plane/space/views/module.py +++ b/apps/api/plane/space/views/module.py @@ -1,3 +1,7 @@ +# 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 import status from rest_framework.permissions import AllowAny diff --git a/apps/api/plane/space/views/project.py b/apps/api/plane/space/views/project.py index 0e19085a0..168c42624 100644 --- a/apps/api/plane/space/views/project.py +++ b/apps/api/plane/space/views/project.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.db.models import Exists, OuterRef diff --git a/apps/api/plane/space/views/state.py b/apps/api/plane/space/views/state.py index c13186600..05b791475 100644 --- a/apps/api/plane/space/views/state.py +++ b/apps/api/plane/space/views/state.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.db.models import Q diff --git a/apps/api/plane/static/logos/Logo.png b/apps/api/plane/static/logos/Logo.png new file mode 100644 index 000000000..385ed57aa Binary files /dev/null and b/apps/api/plane/static/logos/Logo.png differ diff --git a/apps/api/plane/static/logos/github_32px.png b/apps/api/plane/static/logos/github_32px.png new file mode 100644 index 000000000..4a9e5ab8c Binary files /dev/null and b/apps/api/plane/static/logos/github_32px.png differ diff --git a/apps/api/plane/static/logos/linkedin_32px.png b/apps/api/plane/static/logos/linkedin_32px.png new file mode 100644 index 000000000..396e9327d Binary files /dev/null and b/apps/api/plane/static/logos/linkedin_32px.png differ diff --git a/apps/api/plane/static/logos/twitter_32px.png b/apps/api/plane/static/logos/twitter_32px.png new file mode 100644 index 000000000..537562ea7 Binary files /dev/null and b/apps/api/plane/static/logos/twitter_32px.png differ diff --git a/apps/api/plane/static/logos/website_32px.png b/apps/api/plane/static/logos/website_32px.png new file mode 100644 index 000000000..970a13f1c Binary files /dev/null and b/apps/api/plane/static/logos/website_32px.png differ diff --git a/apps/api/plane/tests/__init__.py b/apps/api/plane/tests/__init__.py index 73d90cd21..5f9223043 100644 --- a/apps/api/plane/tests/__init__.py +++ b/apps/api/plane/tests/__init__.py @@ -1 +1,5 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Test package initialization diff --git a/apps/api/plane/tests/apps.py b/apps/api/plane/tests/apps.py index 577414e63..966986969 100644 --- a/apps/api/plane/tests/apps.py +++ b/apps/api/plane/tests/apps.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.apps import AppConfig diff --git a/apps/api/plane/tests/conftest.py b/apps/api/plane/tests/conftest.py index abfede197..870779c42 100644 --- a/apps/api/plane/tests/conftest.py +++ b/apps/api/plane/tests/conftest.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import pytest from rest_framework.test import APIClient from pytest_django.fixtures import django_db_setup diff --git a/apps/api/plane/tests/conftest_external.py b/apps/api/plane/tests/conftest_external.py index cebb768ca..cd5469caa 100644 --- a/apps/api/plane/tests/conftest_external.py +++ b/apps/api/plane/tests/conftest_external.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import pytest from unittest.mock import MagicMock, patch diff --git a/apps/api/plane/tests/contract/__init__.py b/apps/api/plane/tests/contract/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/tests/contract/__init__.py +++ b/apps/api/plane/tests/contract/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/tests/contract/api/__init__.py b/apps/api/plane/tests/contract/api/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/tests/contract/api/__init__.py +++ b/apps/api/plane/tests/contract/api/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/tests/contract/api/test_cycles.py b/apps/api/plane/tests/contract/api/test_cycles.py index 644fe2bef..d0138de8b 100644 --- a/apps/api/plane/tests/contract/api/test_cycles.py +++ b/apps/api/plane/tests/contract/api/test_cycles.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import pytest from rest_framework import status from django.utils import timezone diff --git a/apps/api/plane/tests/contract/api/test_labels.py b/apps/api/plane/tests/contract/api/test_labels.py index a3a43d90a..db5340dfd 100644 --- a/apps/api/plane/tests/contract/api/test_labels.py +++ b/apps/api/plane/tests/contract/api/test_labels.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import pytest from rest_framework import status from uuid import uuid4 diff --git a/apps/api/plane/tests/contract/app/__init__.py b/apps/api/plane/tests/contract/app/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/tests/contract/app/__init__.py +++ b/apps/api/plane/tests/contract/app/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/tests/contract/app/test_api_token.py b/apps/api/plane/tests/contract/app/test_api_token.py index 24fac7bb4..ed071b98c 100644 --- a/apps/api/plane/tests/contract/app/test_api_token.py +++ b/apps/api/plane/tests/contract/app/test_api_token.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import pytest from datetime import timedelta from uuid import uuid4 diff --git a/apps/api/plane/tests/contract/app/test_authentication.py b/apps/api/plane/tests/contract/app/test_authentication.py index 1c044f192..808416b02 100644 --- a/apps/api/plane/tests/contract/app/test_authentication.py +++ b/apps/api/plane/tests/contract/app/test_authentication.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import json import uuid import pytest diff --git a/apps/api/plane/tests/contract/app/test_project_app.py b/apps/api/plane/tests/contract/app/test_project_app.py index 38b0f51f3..979c5e805 100644 --- a/apps/api/plane/tests/contract/app/test_project_app.py +++ b/apps/api/plane/tests/contract/app/test_project_app.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import pytest from rest_framework import status import uuid @@ -6,7 +10,7 @@ from django.utils import timezone from plane.db.models import ( Project, ProjectMember, - IssueUserProperty, + ProjectUserProperty, State, WorkspaceMember, User, @@ -82,8 +86,8 @@ class TestProjectAPIPost(TestProjectBase): assert project_member.role == 20 # Administrator assert project_member.is_active is True - # Verify IssueUserProperty was created - assert IssueUserProperty.objects.filter(project=project, user=user).exists() + # Verify ProjectUserProperty was created + assert ProjectUserProperty.objects.filter(project=project, user=user).exists() # Verify default states were created states = State.objects.filter(project=project) @@ -116,8 +120,8 @@ class TestProjectAPIPost(TestProjectBase): project = Project.objects.get(name=project_data["name"]) assert ProjectMember.objects.filter(project=project, role=20).count() == 2 - # Verify both have IssueUserProperty - assert IssueUserProperty.objects.filter(project=project).count() == 2 + # Verify both have ProjectUserProperty + assert ProjectUserProperty.objects.filter(project=project).count() == 2 @pytest.mark.django_db def test_create_project_guest_forbidden(self, session_client, workspace): diff --git a/apps/api/plane/tests/contract/app/test_workspace_app.py b/apps/api/plane/tests/contract/app/test_workspace_app.py index 47b049795..427bad60b 100644 --- a/apps/api/plane/tests/contract/app/test_workspace_app.py +++ b/apps/api/plane/tests/contract/app/test_workspace_app.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import pytest from django.urls import reverse from rest_framework import status diff --git a/apps/api/plane/tests/factories.py b/apps/api/plane/tests/factories.py index b8cd78361..4d39d832f 100644 --- a/apps/api/plane/tests/factories.py +++ b/apps/api/plane/tests/factories.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import factory from uuid import uuid4 from django.utils import timezone diff --git a/apps/api/plane/tests/smoke/__init__.py b/apps/api/plane/tests/smoke/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/tests/smoke/__init__.py +++ b/apps/api/plane/tests/smoke/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/tests/smoke/test_auth_smoke.py b/apps/api/plane/tests/smoke/test_auth_smoke.py index c5a671e9a..1537db79f 100644 --- a/apps/api/plane/tests/smoke/test_auth_smoke.py +++ b/apps/api/plane/tests/smoke/test_auth_smoke.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import pytest import requests from django.urls import reverse diff --git a/apps/api/plane/tests/unit/__init__.py b/apps/api/plane/tests/unit/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/tests/unit/__init__.py +++ b/apps/api/plane/tests/unit/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py b/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py index 988603659..c153703ba 100644 --- a/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py +++ b/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import pytest from plane.db.models import Project, ProjectMember, Issue, FileAsset from unittest.mock import patch, MagicMock diff --git a/apps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.py b/apps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.py new file mode 100644 index 000000000..2838260e8 --- /dev/null +++ b/apps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.py @@ -0,0 +1,126 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import pytest +from unittest.mock import patch, MagicMock +from plane.bgtasks.work_item_link_task import safe_get, validate_url_ip + + +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 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 diff --git a/apps/api/plane/tests/unit/middleware/__init__.py b/apps/api/plane/tests/unit/middleware/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/tests/unit/middleware/__init__.py +++ b/apps/api/plane/tests/unit/middleware/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/tests/unit/middleware/test_db_routing.py b/apps/api/plane/tests/unit/middleware/test_db_routing.py index 5ac71696a..9f5439e75 100644 --- a/apps/api/plane/tests/unit/middleware/test_db_routing.py +++ b/apps/api/plane/tests/unit/middleware/test_db_routing.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ Unit tests for ReadReplicaRoutingMiddleware. This module contains comprehensive tests for the ReadReplicaRoutingMiddleware diff --git a/apps/api/plane/tests/unit/models/__init__.py b/apps/api/plane/tests/unit/models/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/tests/unit/models/__init__.py +++ b/apps/api/plane/tests/unit/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/tests/unit/models/test_issue_comment_modal.py b/apps/api/plane/tests/unit/models/test_issue_comment_modal.py index 98a0b05b2..37f743d76 100644 --- a/apps/api/plane/tests/unit/models/test_issue_comment_modal.py +++ b/apps/api/plane/tests/unit/models/test_issue_comment_modal.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import pytest from plane.db.models import IssueComment, Description, Project, Issue, Workspace, State diff --git a/apps/api/plane/tests/unit/models/test_workspace_model.py b/apps/api/plane/tests/unit/models/test_workspace_model.py index 26a797512..405538cfb 100644 --- a/apps/api/plane/tests/unit/models/test_workspace_model.py +++ b/apps/api/plane/tests/unit/models/test_workspace_model.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import pytest from uuid import uuid4 diff --git a/apps/api/plane/tests/unit/serializers/__init__.py b/apps/api/plane/tests/unit/serializers/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/tests/unit/serializers/__init__.py +++ b/apps/api/plane/tests/unit/serializers/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py b/apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py index eac92384b..59a909eeb 100644 --- a/apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py +++ b/apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import pytest from plane.db.models import ( diff --git a/apps/api/plane/tests/unit/serializers/test_label.py b/apps/api/plane/tests/unit/serializers/test_label.py index 91cde1c4a..a4ebc8875 100644 --- a/apps/api/plane/tests/unit/serializers/test_label.py +++ b/apps/api/plane/tests/unit/serializers/test_label.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import pytest from plane.app.serializers import LabelSerializer from plane.db.models import Project, Label @@ -10,9 +14,7 @@ class TestLabelSerializer: @pytest.mark.django_db def test_label_serializer_create_valid_data(self, db, workspace): """Test creating a label with valid data""" - project = Project.objects.create( - name="Test Project", identifier="TEST", workspace=workspace - ) + project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace) serializer = LabelSerializer( data={"name": "Test Label"}, @@ -30,14 +32,10 @@ class TestLabelSerializer: @pytest.mark.django_db def test_label_serializer_create_duplicate_name(self, db, workspace): """Test creating a label with a duplicate name""" - project = Project.objects.create( - name="Test Project", identifier="TEST", workspace=workspace - ) + project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace) Label.objects.create(name="Test Label", project=project) - serializer = LabelSerializer( - data={"name": "Test Label"}, context={"project_id": project.id} - ) + serializer = LabelSerializer(data={"name": "Test Label"}, context={"project_id": project.id}) assert not serializer.is_valid() assert serializer.errors == {"name": ["LABEL_NAME_ALREADY_EXISTS"]} diff --git a/apps/api/plane/tests/unit/serializers/test_workspace.py b/apps/api/plane/tests/unit/serializers/test_workspace.py index 21844c714..f59667f70 100644 --- a/apps/api/plane/tests/unit/serializers/test_workspace.py +++ b/apps/api/plane/tests/unit/serializers/test_workspace.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import pytest from uuid import uuid4 diff --git a/apps/api/plane/tests/unit/settings/__init__.py b/apps/api/plane/tests/unit/settings/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/tests/unit/settings/__init__.py +++ b/apps/api/plane/tests/unit/settings/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/tests/unit/settings/test_storage.py b/apps/api/plane/tests/unit/settings/test_storage.py index fe8cf43f8..00856aeec 100644 --- a/apps/api/plane/tests/unit/settings/test_storage.py +++ b/apps/api/plane/tests/unit/settings/test_storage.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import os from unittest.mock import Mock, patch import pytest diff --git a/apps/api/plane/tests/unit/utils/__init__.py b/apps/api/plane/tests/unit/utils/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/tests/unit/utils/__init__.py +++ b/apps/api/plane/tests/unit/utils/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/tests/unit/utils/test_url.py b/apps/api/plane/tests/unit/utils/test_url.py index 465cb3023..82b5b106d 100644 --- a/apps/api/plane/tests/unit/utils/test_url.py +++ b/apps/api/plane/tests/unit/utils/test_url.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import pytest from plane.utils.url import ( contains_url, diff --git a/apps/api/plane/tests/unit/utils/test_uuid.py b/apps/api/plane/tests/unit/utils/test_uuid.py index d47e59c4b..33ddebb92 100644 --- a/apps/api/plane/tests/unit/utils/test_uuid.py +++ b/apps/api/plane/tests/unit/utils/test_uuid.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import uuid import pytest from plane.utils.uuid import is_valid_uuid, convert_uuid_to_integer diff --git a/apps/api/plane/throttles/asset.py b/apps/api/plane/throttles/asset.py index 484650049..bdc3be799 100644 --- a/apps/api/plane/throttles/asset.py +++ b/apps/api/plane/throttles/asset.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from rest_framework.throttling import SimpleRateThrottle diff --git a/apps/api/plane/urls.py b/apps/api/plane/urls.py index 4b1062559..f5e43408c 100644 --- a/apps/api/plane/urls.py +++ b/apps/api/plane/urls.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """plane URL Configuration""" from django.conf import settings diff --git a/apps/api/plane/utils/__init__.py b/apps/api/plane/utils/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/utils/__init__.py +++ b/apps/api/plane/utils/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/utils/analytics_events.py b/apps/api/plane/utils/analytics_events.py new file mode 100644 index 000000000..ce06ba92e --- /dev/null +++ b/apps/api/plane/utils/analytics_events.py @@ -0,0 +1,8 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +USER_JOINED_WORKSPACE = "user_joined_workspace" +USER_INVITED_TO_WORKSPACE = "user_invited_to_workspace" +WORKSPACE_CREATED = "workspace_created" +WORKSPACE_DELETED = "workspace_deleted" diff --git a/apps/api/plane/utils/analytics_plot.py b/apps/api/plane/utils/analytics_plot.py index 12fa39cc0..acd86aca8 100644 --- a/apps/api/plane/utils/analytics_plot.py +++ b/apps/api/plane/utils/analytics_plot.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports from datetime import timedelta from itertools import groupby diff --git a/apps/api/plane/utils/build_chart.py b/apps/api/plane/utils/build_chart.py index 9a2d9c3a0..bf4d1cf2b 100644 --- a/apps/api/plane/utils/build_chart.py +++ b/apps/api/plane/utils/build_chart.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from typing import Dict, Any, Tuple, Optional, List, Union @@ -51,7 +55,7 @@ def get_x_axis_field() -> Dict[str, Tuple[str, str, Optional[Dict[str, Any]]]]: "assignees__display_name", {"issue_assignee__deleted_at__isnull": True}, ), - "ESTIMATE_POINTS": ("estimate_point__value", "estimate_point__key", None), + "ESTIMATE_POINTS": ("estimate_point__key", "estimate_point__value", None), "CYCLES": ( "issue_cycle__cycle_id", "issue_cycle__cycle__name", diff --git a/apps/api/plane/utils/cache.py b/apps/api/plane/utils/cache.py index da3fd4517..9ff5db6d9 100644 --- a/apps/api/plane/utils/cache.py +++ b/apps/api/plane/utils/cache.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports from functools import wraps diff --git a/apps/api/plane/utils/color.py b/apps/api/plane/utils/color.py index 8c45389bd..61a572dc0 100644 --- a/apps/api/plane/utils/color.py +++ b/apps/api/plane/utils/color.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import random import string diff --git a/apps/api/plane/utils/constants.py b/apps/api/plane/utils/constants.py index 0d5e64a20..1ccc501dd 100644 --- a/apps/api/plane/utils/constants.py +++ b/apps/api/plane/utils/constants.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + RESTRICTED_WORKSPACE_SLUGS = [ "404", "accounts", diff --git a/apps/api/plane/utils/content_validator.py b/apps/api/plane/utils/content_validator.py index 10e83b85d..1b4ede262 100644 --- a/apps/api/plane/utils/content_validator.py +++ b/apps/api/plane/utils/content_validator.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import base64 import nh3 @@ -56,9 +60,7 @@ def validate_binary_data(data): # Check for suspicious text patterns (HTML/JS) try: decoded_text = binary_data.decode("utf-8", errors="ignore")[:200] - if any( - pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS - ): + if any(pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS): return False, "Binary data contains suspicious content patterns" except Exception: pass # Binary data might not be decodable as text, which is fine @@ -137,8 +139,6 @@ ATTRIBUTES = { "rowspan", "colwidth", "background", - "hideContent", - "hidecontent", "style", }, "td": { @@ -148,8 +148,6 @@ ATTRIBUTES = { "background", "textColor", "textcolor", - "hideContent", - "hidecontent", "style", }, "tr": {"background", "textColor", "textcolor", "style"}, diff --git a/apps/api/plane/utils/core/__init__.py b/apps/api/plane/utils/core/__init__.py index 37c6e3741..7f119b62f 100644 --- a/apps/api/plane/utils/core/__init__.py +++ b/apps/api/plane/utils/core/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ Core utilities for Plane database routing and request scoping. This package contains essential components for managing read replica routing diff --git a/apps/api/plane/utils/core/dbrouters.py b/apps/api/plane/utils/core/dbrouters.py index e17568331..fdd00cca2 100644 --- a/apps/api/plane/utils/core/dbrouters.py +++ b/apps/api/plane/utils/core/dbrouters.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ Database router for read replica selection. This router determines which database to use for read/write operations diff --git a/apps/api/plane/utils/core/mixins/__init__.py b/apps/api/plane/utils/core/mixins/__init__.py index cedd9d455..73fe2ccc9 100644 --- a/apps/api/plane/utils/core/mixins/__init__.py +++ b/apps/api/plane/utils/core/mixins/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ Core mixins for read replica functionality. This package provides mixins for different aspects of read replica management diff --git a/apps/api/plane/utils/core/mixins/view.py b/apps/api/plane/utils/core/mixins/view.py index e15ec6771..4d923e1c1 100644 --- a/apps/api/plane/utils/core/mixins/view.py +++ b/apps/api/plane/utils/core/mixins/view.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ Mixins for Django REST Framework views. """ diff --git a/apps/api/plane/utils/core/request_scope.py b/apps/api/plane/utils/core/request_scope.py index b09e77101..b8b137120 100644 --- a/apps/api/plane/utils/core/request_scope.py +++ b/apps/api/plane/utils/core/request_scope.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ Database routing utilities for read replica selection. This module provides request-scoped context management for database routing, diff --git a/apps/api/plane/utils/csv_utils.py b/apps/api/plane/utils/csv_utils.py new file mode 100644 index 000000000..26c6e8937 --- /dev/null +++ b/apps/api/plane/utils/csv_utils.py @@ -0,0 +1,26 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# CSV utility functions for safe export +# Characters that trigger formula evaluation in spreadsheet applications +_CSV_FORMULA_TRIGGERS = frozenset(("=", "+", "-", "@", "\t", "\r", "\n")) + + +def sanitize_csv_value(value): + """Sanitize a value for CSV export to prevent formula injection. + + Prefixes string values starting with formula-triggering characters + with a single quote so spreadsheet applications treat them as text + instead of evaluating them as formulas. + + See: https://owasp.org/www-community/attacks/CSV_Injection + """ + if isinstance(value, str) and value and value[0] in _CSV_FORMULA_TRIGGERS: + return "'" + value + return value + + +def sanitize_csv_row(row): + """Sanitize all values in a CSV row.""" + return [sanitize_csv_value(v) for v in row] diff --git a/apps/api/plane/utils/cycle_transfer_issues.py b/apps/api/plane/utils/cycle_transfer_issues.py index ec934e889..796340138 100644 --- a/apps/api/plane/utils/cycle_transfer_issues.py +++ b/apps/api/plane/utils/cycle_transfer_issues.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json @@ -51,9 +55,7 @@ def transfer_cycle_issues( dict: Response data with success or error message """ # Get the new cycle - new_cycle = Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, pk=new_cycle_id - ).first() + new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first() # Check if new cycle is already completed if new_cycle.end_date is not None and new_cycle.end_date < timezone.now(): @@ -216,9 +218,7 @@ def transfer_cycle_issues( assignee_estimate_distribution = [ { "display_name": item["display_name"], - "assignee_id": ( - str(item["assignee_id"]) if item["assignee_id"] else None - ), + "assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None), "avatar_url": item.get("avatar_url"), "total_estimates": item["total_estimates"], "completed_estimates": item["completed_estimates"], @@ -310,9 +310,7 @@ def transfer_cycle_issues( ) ) .values("display_name", "assignee_id", "avatar_url") - .annotate( - total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)) - ) + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) .annotate( completed_issues=Count( "id", @@ -360,9 +358,7 @@ def transfer_cycle_issues( .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate( - total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)) - ) + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) .annotate( completed_issues=Count( "id", @@ -409,9 +405,7 @@ def transfer_cycle_issues( ) # Get the current cycle and save progress snapshot - current_cycle = Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, pk=cycle_id - ).first() + current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first() current_cycle.progress_snapshot = { "total_issues": old_cycle.total_issues, @@ -461,9 +455,7 @@ def transfer_cycle_issues( ) # Bulk update cycle issues - cycle_issues = CycleIssue.objects.bulk_update( - updated_cycles, ["cycle_id"], batch_size=100 - ) + cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100) # Capture Issue Activity issue_activity.delay( diff --git a/apps/api/plane/utils/date_utils.py b/apps/api/plane/utils/date_utils.py index f15e7f119..d25d5b1ec 100644 --- a/apps/api/plane/utils/date_utils.py +++ b/apps/api/plane/utils/date_utils.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from datetime import datetime, timedelta, date from django.utils import timezone from typing import Dict, Optional, List, Union, Tuple, Any diff --git a/apps/api/plane/utils/email.py b/apps/api/plane/utils/email.py new file mode 100644 index 000000000..f950e9451 --- /dev/null +++ b/apps/api/plane/utils/email.py @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2023-present Plane Software, Inc. +# SPDX-License-Identifier: LicenseRef-Plane-Commercial +# +# Licensed under the Plane Commercial License (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# https://plane.so/legals/eula +# +# DO NOT remove or modify this notice. +# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited. + +# Python imports +import re + +# Django imports +from django.utils.html import strip_tags + + +def generate_plain_text_from_html(html_content): + """ + Generate clean plain text from HTML email template. + Removes all HTML tags, CSS styles, and excessive whitespace. + + Args: + html_content (str): The HTML content to convert to plain text + + Returns: + str: Clean plain text without HTML tags, styles, or excessive whitespace + """ + # Remove style tags and their content + html_content = re.sub(r"]*>.*?", "", html_content, flags=re.DOTALL | re.IGNORECASE) + + # Strip HTML tags + text_content = strip_tags(html_content) + + # Remove excessive empty lines + text_content = re.sub(r"\n\s*\n\s*\n+", "\n\n", text_content) + + # Ensure there's a leading and trailing whitespace + text_content = "\n\n" + text_content.lstrip().rstrip() + "\n\n" + + return text_content diff --git a/apps/api/plane/utils/error_codes.py b/apps/api/plane/utils/error_codes.py index 15d38f6bf..571f9d368 100644 --- a/apps/api/plane/utils/error_codes.py +++ b/apps/api/plane/utils/error_codes.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + ERROR_CODES = { # issues "INVALID_ARCHIVE_STATE_GROUP": 4091, diff --git a/apps/api/plane/utils/exception_logger.py b/apps/api/plane/utils/exception_logger.py index b0a6f8c38..657afeb5c 100644 --- a/apps/api/plane/utils/exception_logger.py +++ b/apps/api/plane/utils/exception_logger.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import logging import traceback diff --git a/apps/api/plane/utils/exporters/__init__.py b/apps/api/plane/utils/exporters/__init__.py index 9e7b1a9d5..632452a31 100644 --- a/apps/api/plane/utils/exporters/__init__.py +++ b/apps/api/plane/utils/exporters/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """Export utilities for various data formats.""" from .exporter import Exporter diff --git a/apps/api/plane/utils/exporters/exporter.py b/apps/api/plane/utils/exporters/exporter.py index 75b396cb4..ff4df46c7 100644 --- a/apps/api/plane/utils/exporters/exporter.py +++ b/apps/api/plane/utils/exporters/exporter.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from typing import Any, Dict, List, Type, Union from django.db.models import QuerySet diff --git a/apps/api/plane/utils/exporters/formatters.py b/apps/api/plane/utils/exporters/formatters.py index fc7c23528..611a60fca 100644 --- a/apps/api/plane/utils/exporters/formatters.py +++ b/apps/api/plane/utils/exporters/formatters.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import csv import io import json @@ -5,6 +9,9 @@ from typing import Any, Dict, List, Type from openpyxl import Workbook +# Module imports +from plane.utils.csv_utils import sanitize_csv_row + class BaseFormatter: """Base class for export formatters.""" @@ -80,7 +87,7 @@ class CSVFormatter(BaseFormatter): buf = io.StringIO() writer = csv.writer(buf, delimiter=",", quoting=csv.QUOTE_ALL) for row in data: - writer.writerow(row) + writer.writerow(sanitize_csv_row(row)) buf.seek(0) return buf.getvalue() diff --git a/apps/api/plane/utils/exporters/schemas/__init__.py b/apps/api/plane/utils/exporters/schemas/__init__.py index 98b2623ae..e792b3c6f 100644 --- a/apps/api/plane/utils/exporters/schemas/__init__.py +++ b/apps/api/plane/utils/exporters/schemas/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """Export schemas for various data types.""" from .base import ( diff --git a/apps/api/plane/utils/exporters/schemas/base.py b/apps/api/plane/utils/exporters/schemas/base.py index 4e67c6980..eacee3741 100644 --- a/apps/api/plane/utils/exporters/schemas/base.py +++ b/apps/api/plane/utils/exporters/schemas/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from dataclasses import dataclass, field from typing import Any, Dict, List, Optional diff --git a/apps/api/plane/utils/exporters/schemas/issue.py b/apps/api/plane/utils/exporters/schemas/issue.py index 744e33052..a3bda90b7 100644 --- a/apps/api/plane/utils/exporters/schemas/issue.py +++ b/apps/api/plane/utils/exporters/schemas/issue.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from collections import defaultdict from typing import Any, Dict, List, Optional diff --git a/apps/api/plane/utils/filters/__init__.py b/apps/api/plane/utils/filters/__init__.py index 76a96c82c..cdcf8ac6e 100644 --- a/apps/api/plane/utils/filters/__init__.py +++ b/apps/api/plane/utils/filters/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Filters module for handling complex filtering operations # Import all utilities from base modules diff --git a/apps/api/plane/utils/filters/converters.py b/apps/api/plane/utils/filters/converters.py index f7693b40e..4d37c2b0b 100644 --- a/apps/api/plane/utils/filters/converters.py +++ b/apps/api/plane/utils/filters/converters.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import re import uuid from datetime import datetime diff --git a/apps/api/plane/utils/filters/filter_backend.py b/apps/api/plane/utils/filters/filter_backend.py index 11ed48f71..c21560f70 100644 --- a/apps/api/plane/utils/filters/filter_backend.py +++ b/apps/api/plane/utils/filters/filter_backend.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import json diff --git a/apps/api/plane/utils/filters/filter_migrations.py b/apps/api/plane/utils/filters/filter_migrations.py index 3e424b6e6..555793dc2 100644 --- a/apps/api/plane/utils/filters/filter_migrations.py +++ b/apps/api/plane/utils/filters/filter_migrations.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ Utilities for migrating legacy filters to rich filters format. diff --git a/apps/api/plane/utils/filters/filterset.py b/apps/api/plane/utils/filters/filterset.py index 0099b83d0..721bf4c7a 100644 --- a/apps/api/plane/utils/filters/filterset.py +++ b/apps/api/plane/utils/filters/filterset.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import copy from django.db import models diff --git a/apps/api/plane/utils/global_paginator.py b/apps/api/plane/utils/global_paginator.py index 1b7f908c5..e9b68ba76 100644 --- a/apps/api/plane/utils/global_paginator.py +++ b/apps/api/plane/utils/global_paginator.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # python imports from math import ceil diff --git a/apps/api/plane/utils/grouper.py b/apps/api/plane/utils/grouper.py index 1ec004e95..ab0087967 100644 --- a/apps/api/plane/utils/grouper.py +++ b/apps/api/plane/utils/grouper.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField diff --git a/apps/api/plane/utils/host.py b/apps/api/plane/utils/host.py index 860e19e0e..dafd19179 100644 --- a/apps/api/plane/utils/host.py +++ b/apps/api/plane/utils/host.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.conf import settings from django.core.exceptions import ImproperlyConfigured diff --git a/apps/api/plane/utils/html_processor.py b/apps/api/plane/utils/html_processor.py index 18d103b64..a26f6fe13 100644 --- a/apps/api/plane/utils/html_processor.py +++ b/apps/api/plane/utils/html_processor.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from io import StringIO from html.parser import HTMLParser diff --git a/apps/api/plane/utils/imports.py b/apps/api/plane/utils/imports.py index 81de0203b..af86c31e7 100644 --- a/apps/api/plane/utils/imports.py +++ b/apps/api/plane/utils/imports.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import pkgutil import six diff --git a/apps/api/plane/utils/instance_config_variables/__init__.py b/apps/api/plane/utils/instance_config_variables/__init__.py index 6818ca9bf..09882ae11 100644 --- a/apps/api/plane/utils/instance_config_variables/__init__.py +++ b/apps/api/plane/utils/instance_config_variables/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .core import core_config_variables from .extended import extended_config_variables diff --git a/apps/api/plane/utils/instance_config_variables/core.py b/apps/api/plane/utils/instance_config_variables/core.py index cf8d8d41f..274c6539a 100644 --- a/apps/api/plane/utils/instance_config_variables/core.py +++ b/apps/api/plane/utils/instance_config_variables/core.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os @@ -44,6 +48,12 @@ google_config_variables = [ "category": "GOOGLE", "is_encrypted": True, }, + { + "key": "ENABLE_GOOGLE_SYNC", + "value": os.environ.get("ENABLE_GOOGLE_SYNC", "0"), + "category": "GOOGLE", + "is_encrypted": False, + }, ] github_config_variables = [ @@ -65,6 +75,12 @@ github_config_variables = [ "category": "GITHUB", "is_encrypted": False, }, + { + "key": "ENABLE_GITHUB_SYNC", + "value": os.environ.get("ENABLE_GITHUB_SYNC", "0"), + "category": "GITHUB", + "is_encrypted": False, + }, ] @@ -87,6 +103,12 @@ gitlab_config_variables = [ "category": "GITLAB", "is_encrypted": True, }, + { + "key": "ENABLE_GITLAB_SYNC", + "value": os.environ.get("ENABLE_GITLAB_SYNC", "0"), + "category": "GITLAB", + "is_encrypted": False, + }, ] gitea_config_variables = [ @@ -114,6 +136,12 @@ gitea_config_variables = [ "category": "GITEA", "is_encrypted": True, }, + { + "key": "ENABLE_GITEA_SYNC", + "value": os.environ.get("ENABLE_GITEA_SYNC", "0"), + "category": "GITEA", + "is_encrypted": False, + }, ] smtp_config_variables = [ diff --git a/apps/api/plane/utils/instance_config_variables/extended.py b/apps/api/plane/utils/instance_config_variables/extended.py index 24c6fefda..cf267aca2 100644 --- a/apps/api/plane/utils/instance_config_variables/extended.py +++ b/apps/api/plane/utils/instance_config_variables/extended.py @@ -1 +1,5 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + extended_config_variables = [] diff --git a/apps/api/plane/utils/ip_address.py b/apps/api/plane/utils/ip_address.py index 01789c431..3a0f171d7 100644 --- a/apps/api/plane/utils/ip_address.py +++ b/apps/api/plane/utils/ip_address.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + def get_client_ip(request): x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") if x_forwarded_for: diff --git a/apps/api/plane/utils/issue_filters.py b/apps/api/plane/utils/issue_filters.py index 8d56bc389..ea31a529b 100644 --- a/apps/api/plane/utils/issue_filters.py +++ b/apps/api/plane/utils/issue_filters.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import re import uuid from datetime import timedelta diff --git a/apps/api/plane/utils/issue_relation_mapper.py b/apps/api/plane/utils/issue_relation_mapper.py index 19d65c111..ecce5a2d1 100644 --- a/apps/api/plane/utils/issue_relation_mapper.py +++ b/apps/api/plane/utils/issue_relation_mapper.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + def get_inverse_relation(relation_type): relation_mapping = { "start_after": "start_before", diff --git a/apps/api/plane/utils/issue_search.py b/apps/api/plane/utils/issue_search.py index 1e7543d88..7e5fab8fe 100644 --- a/apps/api/plane/utils/issue_search.py +++ b/apps/api/plane/utils/issue_search.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import re diff --git a/apps/api/plane/utils/logging.py b/apps/api/plane/utils/logging.py index 083132f16..61312448d 100644 --- a/apps/api/plane/utils/logging.py +++ b/apps/api/plane/utils/logging.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import logging.handlers as handlers import time diff --git a/apps/api/plane/utils/markdown.py b/apps/api/plane/utils/markdown.py index 188c54fec..643dd7788 100644 --- a/apps/api/plane/utils/markdown.py +++ b/apps/api/plane/utils/markdown.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import mistune markdown = mistune.Markdown() diff --git a/apps/api/plane/utils/openapi/__init__.py b/apps/api/plane/utils/openapi/__init__.py index b2c9ba6b0..090d076ec 100644 --- a/apps/api/plane/utils/openapi/__init__.py +++ b/apps/api/plane/utils/openapi/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ OpenAPI utilities for drf-spectacular integration. @@ -43,6 +47,7 @@ from .parameters import ( CYCLE_VIEW_PARAMETER, FIELDS_PARAMETER, EXPAND_PARAMETER, + ESTIMATE_ID_PARAMETER, ) # Responses @@ -122,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, @@ -141,6 +150,8 @@ from .examples import ( PROJECT_MEMBER_EXAMPLE, CYCLE_ISSUE_EXAMPLE, STICKY_EXAMPLE, + ESTIMATE_EXAMPLE, + ESTIMATE_POINT_EXAMPLE, ) # Helper decorators @@ -153,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, @@ -161,6 +173,8 @@ from .decorators import ( module_docs, module_issue_docs, state_docs, + estimate_docs, + estimate_point_docs, ) # Schema processing hooks @@ -202,6 +216,7 @@ __all__ = [ "CYCLE_VIEW_PARAMETER", "FIELDS_PARAMETER", "EXPAND_PARAMETER", + "ESTIMATE_ID_PARAMETER", # Responses "UNAUTHORIZED_RESPONSE", "FORBIDDEN_RESPONSE", @@ -275,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", @@ -294,6 +313,8 @@ __all__ = [ "PROJECT_MEMBER_EXAMPLE", "CYCLE_ISSUE_EXAMPLE", "STICKY_EXAMPLE", + "ESTIMATE_EXAMPLE", + "ESTIMATE_POINT_EXAMPLE", # Decorators "workspace_docs", "project_docs", @@ -303,6 +324,7 @@ __all__ = [ "user_docs", "cycle_docs", "work_item_docs", + "work_item_relation_docs", "label_docs", "issue_link_docs", "issue_comment_docs", @@ -311,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", diff --git a/apps/api/plane/utils/openapi/auth.py b/apps/api/plane/utils/openapi/auth.py index 9434956fe..6f7459ea2 100644 --- a/apps/api/plane/utils/openapi/auth.py +++ b/apps/api/plane/utils/openapi/auth.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ OpenAPI authentication extensions for drf-spectacular. diff --git a/apps/api/plane/utils/openapi/decorators.py b/apps/api/plane/utils/openapi/decorators.py index c1ba9612e..7ded9fb10 100644 --- a/apps/api/plane/utils/openapi/decorators.py +++ b/apps/api/plane/utils/openapi/decorators.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ Helper decorators for drf-spectacular OpenAPI documentation. @@ -219,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 = { @@ -263,6 +282,7 @@ def state_docs(**kwargs): return extend_schema(**_merge_schema_options(defaults, kwargs)) + def sticky_docs(**kwargs): """Decorator for sticky management endpoints""" defaults = { @@ -276,4 +296,30 @@ 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)) \ No newline at end of file diff --git a/apps/api/plane/utils/openapi/examples.py b/apps/api/plane/utils/openapi/examples.py index f41bdddbc..20aff1895 100644 --- a/apps/api/plane/utils/openapi/examples.py +++ b/apps/api/plane/utils/openapi/examples.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ Common OpenAPI examples for drf-spectacular. @@ -682,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", @@ -797,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, @@ -812,6 +897,8 @@ SCHEMA_EXAMPLES = { "Intake": SAMPLE_INTAKE, "CycleIssue": SAMPLE_CYCLE_ISSUE, "Sticky": SAMPLE_STICKY, + "Estimate": SAMPLE_ESTIMATE, + "EstimatePoint": SAMPLE_ESTIMATE_POINT, } diff --git a/apps/api/plane/utils/openapi/hooks.py b/apps/api/plane/utils/openapi/hooks.py index f136324c0..20319285b 100644 --- a/apps/api/plane/utils/openapi/hooks.py +++ b/apps/api/plane/utils/openapi/hooks.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ Schema processing hooks for drf-spectacular OpenAPI generation. diff --git a/apps/api/plane/utils/openapi/parameters.py b/apps/api/plane/utils/openapi/parameters.py index 47db747ac..2812892ec 100644 --- a/apps/api/plane/utils/openapi/parameters.py +++ b/apps/api/plane/utils/openapi/parameters.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ Common OpenAPI parameters for drf-spectacular. @@ -491,3 +495,11 @@ EXPAND_PARAMETER = OpenApiParameter( ), ], ) + +ESTIMATE_ID_PARAMETER = OpenApiParameter( + name="estimate_id", + description="Estimate ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, +) diff --git a/apps/api/plane/utils/openapi/responses.py b/apps/api/plane/utils/openapi/responses.py index 2a569e377..cb0f81dce 100644 --- a/apps/api/plane/utils/openapi/responses.py +++ b/apps/api/plane/utils/openapi/responses.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ Common OpenAPI responses for drf-spectacular. diff --git a/apps/api/plane/utils/order_queryset.py b/apps/api/plane/utils/order_queryset.py index 167cd0693..abc0bbca0 100644 --- a/apps/api/plane/utils/order_queryset.py +++ b/apps/api/plane/utils/order_queryset.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.db.models import Case, CharField, Min, Value, When # Custom ordering for priority and state diff --git a/apps/api/plane/utils/paginator.py b/apps/api/plane/utils/paginator.py index f3a794756..5ae4d3815 100644 --- a/apps/api/plane/utils/paginator.py +++ b/apps/api/plane/utils/paginator.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import math from collections import defaultdict diff --git a/apps/api/plane/utils/path_validator.py b/apps/api/plane/utils/path_validator.py index ede3f1161..f15fb4ca9 100644 --- a/apps/api/plane/utils/path_validator.py +++ b/apps/api/plane/utils/path_validator.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Django imports from django.utils.http import url_has_allowed_host_and_scheme from django.conf import settings diff --git a/apps/api/plane/utils/permissions/__init__.py b/apps/api/plane/utils/permissions/__init__.py index 849f7ba3e..22d27694e 100644 --- a/apps/api/plane/utils/permissions/__init__.py +++ b/apps/api/plane/utils/permissions/__init__.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .workspace import ( WorkSpaceBasePermission, WorkspaceOwnerPermission, diff --git a/apps/api/plane/utils/permissions/base.py b/apps/api/plane/utils/permissions/base.py index a2b1a18ff..7b243cbb7 100644 --- a/apps/api/plane/utils/permissions/base.py +++ b/apps/api/plane/utils/permissions/base.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from plane.db.models import WorkspaceMember, ProjectMember from functools import wraps from rest_framework.response import Response diff --git a/apps/api/plane/utils/permissions/page.py b/apps/api/plane/utils/permissions/page.py index bea878f4c..844ff4daf 100644 --- a/apps/api/plane/utils/permissions/page.py +++ b/apps/api/plane/utils/permissions/page.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from plane.db.models import ProjectMember, Page from plane.app.permissions import ROLE diff --git a/apps/api/plane/utils/permissions/project.py b/apps/api/plane/utils/permissions/project.py index a8c0f92a2..55550b27a 100644 --- a/apps/api/plane/utils/permissions/project.py +++ b/apps/api/plane/utils/permissions/project.py @@ -1,3 +1,7 @@ +# 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.permissions import SAFE_METHODS, BasePermission diff --git a/apps/api/plane/utils/permissions/workspace.py b/apps/api/plane/utils/permissions/workspace.py index 8dc791c0c..ada16ec3b 100644 --- a/apps/api/plane/utils/permissions/workspace.py +++ b/apps/api/plane/utils/permissions/workspace.py @@ -1,3 +1,7 @@ +# 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.permissions import BasePermission, SAFE_METHODS diff --git a/apps/api/plane/utils/porters/__init__.py b/apps/api/plane/utils/porters/__init__.py new file mode 100644 index 000000000..5e2cf79e8 --- /dev/null +++ b/apps/api/plane/utils/porters/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter +from .exporter import DataExporter +from .serializers import IssueExportSerializer + +__all__ = [ + # Formatters + "BaseFormatter", + "CSVFormatter", + "JSONFormatter", + "XLSXFormatter", + # Exporters + "DataExporter", + # Export Serializers + "IssueExportSerializer", +] diff --git a/apps/api/plane/utils/porters/exporter.py b/apps/api/plane/utils/porters/exporter.py new file mode 100644 index 000000000..394a2bb0f --- /dev/null +++ b/apps/api/plane/utils/porters/exporter.py @@ -0,0 +1,107 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from typing import Dict, List, Union +from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter + + +class DataExporter: + """ + Export data using DRF serializers with built-in format support. + + Usage: + # New simplified interface + exporter = DataExporter(BookSerializer, format_type='csv') + filename, content = exporter.export('books_export', queryset) + + # Legacy interface (still supported) + exporter = DataExporter(BookSerializer) + csv_string = exporter.to_string(queryset, CSVFormatter()) + """ + + # Available formatters + FORMATTERS = { + "csv": CSVFormatter, + "json": JSONFormatter, + "xlsx": XLSXFormatter, + } + + def __init__(self, serializer_class, format_type: str = None, **serializer_kwargs): + """ + Initialize exporter with serializer and optional format type. + + Args: + serializer_class: DRF serializer class to use for data serialization + format_type: Optional format type (csv, json, xlsx). If provided, enables export() method. + **serializer_kwargs: Additional kwargs to pass to serializer + """ + self.serializer_class = serializer_class + self.serializer_kwargs = serializer_kwargs + self.format_type = format_type + self.formatter = None + + if format_type: + if format_type not in self.FORMATTERS: + raise ValueError(f"Unsupported format: {format_type}. Available: {list(self.FORMATTERS.keys())}") + # Create formatter with default options + self.formatter = self._create_formatter(format_type) + + def _create_formatter(self, format_type: str) -> BaseFormatter: + """Create formatter instance with appropriate options.""" + formatter_class = self.FORMATTERS[format_type] + + # Apply format-specific options + if format_type == "xlsx": + return formatter_class(list_joiner=", ") + else: + return formatter_class() + + def serialize(self, queryset) -> List[Dict]: + """QuerySet → list of dicts""" + serializer = self.serializer_class( + queryset, + many=True, + **self.serializer_kwargs + ) + return serializer.data + + def export(self, filename: str, queryset) -> tuple[str, Union[str, bytes]]: + """ + Export queryset to file with configured format. + + Args: + filename: Base filename (without extension) + queryset: Django QuerySet to export + + Returns: + Tuple of (filename_with_extension, content) + + Raises: + ValueError: If format_type was not provided during initialization + """ + if not self.formatter: + raise ValueError("format_type must be provided during initialization to use export() method") + + data = self.serialize(queryset) + content = self.formatter.encode(data) + full_filename = f"{filename}.{self.formatter.extension}" + + return full_filename, content + + def to_string(self, queryset, formatter: BaseFormatter) -> Union[str, bytes]: + """Export to formatted string (legacy interface)""" + data = self.serialize(queryset) + return formatter.encode(data) + + def to_file(self, queryset, filepath: str, formatter: BaseFormatter) -> str: + """Export to file (legacy interface)""" + content = self.to_string(queryset, formatter) + with open(filepath, 'w', encoding='utf-8') as f: + f.write(content) + return filepath + + @classmethod + def get_available_formats(cls) -> List[str]: + """Get list of available export formats.""" + return list(cls.FORMATTERS.keys()) diff --git a/apps/api/plane/utils/porters/formatters.py b/apps/api/plane/utils/porters/formatters.py new file mode 100644 index 000000000..461a6a5e4 --- /dev/null +++ b/apps/api/plane/utils/porters/formatters.py @@ -0,0 +1,274 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +""" +Import/Export System with Pluggable Formatters + +Exporter: QuerySet → Serializer → Formatter → File/String +Importer: File/String → Formatter → Serializer → Models +""" + +import csv +import json +from abc import ABC, abstractmethod +from io import BytesIO, StringIO +from typing import Any, Dict, List, Union + +from openpyxl import Workbook, load_workbook + + +# Module imports +from plane.utils.csv_utils import sanitize_csv_row, sanitize_csv_value + + +class BaseFormatter(ABC): + @abstractmethod + def encode(self, data: List[Dict]) -> Union[str, bytes]: + """Data → formatted string/bytes""" + pass + + @abstractmethod + def decode(self, content: Union[str, bytes]) -> List[Dict]: + """Formatted string/bytes → data""" + pass + + @property + @abstractmethod + def extension(self) -> str: + pass + + +class JSONFormatter(BaseFormatter): + def __init__(self, indent: int = 2): + self.indent = indent + + def encode(self, data: List[Dict]) -> str: + return json.dumps(data, indent=self.indent, default=str) + + def decode(self, content: str) -> List[Dict]: + return json.loads(content) + + @property + def extension(self) -> str: + return "json" + + +class CSVFormatter(BaseFormatter): + def __init__(self, flatten: bool = True, delimiter: str = ",", prettify_headers: bool = True): + """ + Args: + flatten: Whether to flatten nested dicts. + delimiter: CSV delimiter character. + prettify_headers: If True, transforms 'created_by_name' → 'Created By Name'. + """ + self.flatten = flatten + self.delimiter = delimiter + self.prettify_headers = prettify_headers + + def _prettify_header(self, header: str) -> str: + """Transform 'created_by_name' → 'Created By Name'""" + return header.replace("_", " ").title() + + def _normalize_header(self, header: str) -> str: + """Transform 'Display Name' → 'display_name' (reverse of prettify)""" + return header.strip().lower().replace(" ", "_") + + def _flatten(self, row: Dict, parent_key: str = "") -> Dict: + items = {} + for key, value in row.items(): + new_key = f"{parent_key}__{key}" if parent_key else key + if isinstance(value, dict): + items.update(self._flatten(value, new_key)) + elif isinstance(value, list): + items[new_key] = json.dumps(value) + else: + items[new_key] = value + return items + + def _unflatten(self, row: Dict) -> Dict: + result = {} + for key, value in row.items(): + parts = key.split("__") + current = result + for part in parts[:-1]: + current = current.setdefault(part, {}) + + if isinstance(value, str): + try: + parsed = json.loads(value) + if isinstance(parsed, (list, dict)): + value = parsed + except (json.JSONDecodeError, TypeError): + pass + + current[parts[-1]] = value + return result + + def encode(self, data: List[Dict]) -> str: + if not data: + return "" + + if self.flatten: + data = [self._flatten(row) for row in data] + + # Collect all unique field names in order + fieldnames = [] + for row in data: + for key in row.keys(): + if key not in fieldnames: + fieldnames.append(key) + + output = StringIO() + + if self.prettify_headers: + # Create header mapping: original_key → Pretty Header + header_map = {key: self._prettify_header(key) for key in fieldnames} + pretty_headers = [header_map[key] for key in fieldnames] + + # Write pretty headers manually, then write data rows + writer = csv.writer(output, delimiter=self.delimiter) + writer.writerow(pretty_headers) + + # Write data rows in the same field order + for row in data: + writer.writerow(sanitize_csv_row([row.get(key, "") for key in fieldnames])) + else: + writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=self.delimiter) + writer.writeheader() + for row in data: + writer.writerow({k: sanitize_csv_value(row.get(k, "")) for k in fieldnames}) + + return output.getvalue() + + def decode(self, content: str, normalize_headers: bool = True) -> List[Dict]: + """ + Decode CSV content to list of dicts. + + Args: + content: CSV string + normalize_headers: If True, converts 'Display Name' → 'display_name' + """ + rows = list(csv.DictReader(StringIO(content), delimiter=self.delimiter)) + + # Normalize headers: 'Email' → 'email', 'Display Name' → 'display_name' + if normalize_headers: + rows = [{self._normalize_header(k): v for k, v in row.items()} for row in rows] + + if self.flatten: + rows = [self._unflatten(row) for row in rows] + + return rows + + @property + def extension(self) -> str: + return "csv" + + +class XLSXFormatter(BaseFormatter): + """Formatter for XLSX (Excel) files using openpyxl.""" + + def __init__(self, prettify_headers: bool = True, list_joiner: str = ", "): + """ + Args: + prettify_headers: If True, transforms 'created_by_name' → 'Created By Name'. + list_joiner: String to join list values (default: ", "). + """ + self.prettify_headers = prettify_headers + self.list_joiner = list_joiner + + def _prettify_header(self, header: str) -> str: + """Transform 'created_by_name' → 'Created By Name'""" + return header.replace("_", " ").title() + + def _normalize_header(self, header: str) -> str: + """Transform 'Display Name' → 'display_name' (reverse of prettify)""" + return header.strip().lower().replace(" ", "_") + + def _format_value(self, value: Any) -> Any: + """Format a value for XLSX cell.""" + if value is None: + return "" + if isinstance(value, list): + return self.list_joiner.join(str(v) for v in value) + if isinstance(value, dict): + return json.dumps(value) + return value + + def encode(self, data: List[Dict]) -> bytes: + """Encode data to XLSX bytes.""" + wb = Workbook() + ws = wb.active + + if not data: + # Return empty workbook + output = BytesIO() + wb.save(output) + output.seek(0) + return output.getvalue() + + # Collect all unique field names in order + fieldnames = [] + for row in data: + for key in row.keys(): + if key not in fieldnames: + fieldnames.append(key) + + # Write header row + if self.prettify_headers: + headers = [self._prettify_header(key) for key in fieldnames] + else: + headers = fieldnames + ws.append(headers) + + # Write data rows + for row in data: + ws.append([self._format_value(row.get(key, "")) for key in fieldnames]) + + output = BytesIO() + wb.save(output) + output.seek(0) + return output.getvalue() + + def decode(self, content: bytes, normalize_headers: bool = True) -> List[Dict]: + """ + Decode XLSX bytes to list of dicts. + + Args: + content: XLSX file bytes + normalize_headers: If True, converts 'Display Name' → 'display_name' + """ + wb = load_workbook(filename=BytesIO(content), read_only=True, data_only=True) + ws = wb.active + + rows = list(ws.iter_rows(values_only=True)) + if not rows: + return [] + + # First row is headers + headers = list(rows[0]) + if normalize_headers: + headers = [self._normalize_header(str(h)) if h else "" for h in headers] + + # Convert remaining rows to dicts + result = [] + for row in rows[1:]: + row_dict = {} + for i, value in enumerate(row): + if i < len(headers) and headers[i]: + # Try to parse JSON strings back to lists/dicts + if isinstance(value, str): + try: + parsed = json.loads(value) + if isinstance(parsed, (list, dict)): + value = parsed + except (json.JSONDecodeError, TypeError): + pass + row_dict[headers[i]] = value + result.append(row_dict) + + return result + + @property + def extension(self) -> str: + return "xlsx" diff --git a/apps/api/plane/utils/porters/serializers/__init__.py b/apps/api/plane/utils/porters/serializers/__init__.py new file mode 100644 index 000000000..e4e4bb762 --- /dev/null +++ b/apps/api/plane/utils/porters/serializers/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from .issue import IssueExportSerializer + +__all__ = [ + # Export Serializers + "IssueExportSerializer", +] diff --git a/apps/api/plane/utils/porters/serializers/issue.py b/apps/api/plane/utils/porters/serializers/issue.py new file mode 100644 index 000000000..31be812cc --- /dev/null +++ b/apps/api/plane/utils/porters/serializers/issue.py @@ -0,0 +1,145 @@ +# 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 import serializers + +# Module imports +from plane.app.serializers import IssueSerializer + + +class IssueExportSerializer(IssueSerializer): + """ + Export-optimized serializer that extends IssueSerializer with human-readable fields. + + Converts UUIDs to readable values for CSV/JSON export. + """ + + identifier = serializers.SerializerMethodField() + project_name = serializers.CharField(source='project.name', read_only=True, default="") + project_identifier = serializers.CharField(source='project.identifier', read_only=True, default="") + state_name = serializers.CharField(source='state.name', read_only=True, default="") + created_by_name = serializers.CharField(source='created_by.full_name', read_only=True, default="") + + assignees = serializers.SerializerMethodField() + parent = serializers.SerializerMethodField() + labels = serializers.SerializerMethodField() + cycles = serializers.SerializerMethodField() + modules = serializers.SerializerMethodField() + comments = serializers.SerializerMethodField() + estimate = serializers.SerializerMethodField() + links = serializers.SerializerMethodField() + relations = serializers.SerializerMethodField() + subscribers = serializers.SerializerMethodField() + + class Meta(IssueSerializer.Meta): + fields = [ + "project_name", + "project_identifier", + "parent", + "identifier", + "sequence_id", + "name", + "state_name", + "priority", + "assignees", + "subscribers", + "created_by_name", + "start_date", + "target_date", + "completed_at", + "created_at", + "updated_at", + "archived_at", + "estimate", + "labels", + "cycles", + "modules", + "links", + "relations", + "comments", + "sub_issues_count", + "link_count", + "attachment_count", + "is_draft", + ] + + def get_identifier(self, obj): + return f"{obj.project.identifier}-{obj.sequence_id}" + + def get_assignees(self, obj): + return [u.full_name for u in obj.assignees.all() if u.is_active] + + def get_subscribers(self, obj): + """Return list of subscriber names.""" + return [sub.subscriber.full_name for sub in obj.issue_subscribers.all() if sub.subscriber] + + def get_parent(self, obj): + if not obj.parent: + return "" + return f"{obj.parent.project.identifier}-{obj.parent.sequence_id}" + + def get_labels(self, obj): + return [ + il.label.name + for il in obj.label_issue.all() + if il.deleted_at is None + ] + + def get_cycles(self, obj): + return [ic.cycle.name for ic in obj.issue_cycle.all()] + + def get_modules(self, obj): + return [im.module.name for im in obj.issue_module.all()] + + def get_estimate(self, obj): + """Return estimate point value.""" + if obj.estimate_point: + return obj.estimate_point.value if hasattr(obj.estimate_point, 'value') else str(obj.estimate_point) + return "" + + def get_links(self, obj): + """Return list of issue links with titles.""" + return [ + { + "url": link.url, + "title": link.title if link.title else link.url, + } + for link in obj.issue_link.all() + ] + + def get_relations(self, obj): + """Return list of related issues.""" + relations = [] + + # Outgoing relations (this issue relates to others) + for rel in obj.issue_relation.all(): + if rel.related_issue: + relations.append({ + "type": rel.relation_type if hasattr(rel, 'relation_type') else "related", + "issue": f"{rel.related_issue.project.identifier}-{rel.related_issue.sequence_id}", + "direction": "outgoing" + }) + + # Incoming relations (other issues relate to this one) + for rel in obj.issue_related.all(): + if rel.issue: + relations.append({ + "type": rel.relation_type if hasattr(rel, 'relation_type') else "related", + "issue": f"{rel.issue.project.identifier}-{rel.issue.sequence_id}", + "direction": "incoming" + }) + + return relations + + def get_comments(self, obj): + """Return list of comments with author and timestamp.""" + return [ + { + "comment": comment.comment_stripped if hasattr(comment, 'comment_stripped') else comment.comment_html, + "created_by": comment.actor.full_name if comment.actor else "", + "created_at": comment.created_at.strftime("%Y-%m-%d %H:%M:%S") if comment.created_at else "", + } + for comment in obj.issue_comments.all() + ] diff --git a/apps/api/plane/utils/telemetry.py b/apps/api/plane/utils/telemetry.py index bec3d240d..e3646eaba 100644 --- a/apps/api/plane/utils/telemetry.py +++ b/apps/api/plane/utils/telemetry.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import os import atexit diff --git a/apps/api/plane/utils/timezone_converter.py b/apps/api/plane/utils/timezone_converter.py index 9a66742ed..81aa3692d 100644 --- a/apps/api/plane/utils/timezone_converter.py +++ b/apps/api/plane/utils/timezone_converter.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import pytz from datetime import datetime, time diff --git a/apps/api/plane/utils/url.py b/apps/api/plane/utils/url.py index 773608bd3..8381d65f9 100644 --- a/apps/api/plane/utils/url.py +++ b/apps/api/plane/utils/url.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import re from typing import Optional diff --git a/apps/api/plane/utils/uuid.py b/apps/api/plane/utils/uuid.py index 03f695fdb..2d95d5906 100644 --- a/apps/api/plane/utils/uuid.py +++ b/apps/api/plane/utils/uuid.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + # Python imports import uuid import hashlib diff --git a/apps/api/plane/web/__init__.py b/apps/api/plane/web/__init__.py index e69de29bb..917e26db4 100644 --- a/apps/api/plane/web/__init__.py +++ b/apps/api/plane/web/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + diff --git a/apps/api/plane/web/apps.py b/apps/api/plane/web/apps.py index a5861f9b5..1193cd6ae 100644 --- a/apps/api/plane/web/apps.py +++ b/apps/api/plane/web/apps.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.apps import AppConfig diff --git a/apps/api/plane/web/urls.py b/apps/api/plane/web/urls.py index 28734ad91..fe1f8951a 100644 --- a/apps/api/plane/web/urls.py +++ b/apps/api/plane/web/urls.py @@ -1,3 +1,7 @@ +# 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.web.views import robots_txt, health_check diff --git a/apps/api/plane/web/views.py b/apps/api/plane/web/views.py index 8acb70a77..c2c42710e 100644 --- a/apps/api/plane/web/views.py +++ b/apps/api/plane/web/views.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from django.http import HttpResponse, JsonResponse diff --git a/apps/api/plane/wsgi.py b/apps/api/plane/wsgi.py index b3051f9ff..4c8a79163 100644 --- a/apps/api/plane/wsgi.py +++ b/apps/api/plane/wsgi.py @@ -1,3 +1,7 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + """ WSGI config for plane project. diff --git a/apps/api/requirements/base.txt b/apps/api/requirements/base.txt index b0ffb54e8..865590cb2 100644 --- a/apps/api/requirements/base.txt +++ b/apps/api/requirements/base.txt @@ -1,7 +1,7 @@ # base requirements # django -Django==4.2.28 +Django==4.2.29 # rest framework djangorestframework==3.15.2 # postgres @@ -21,7 +21,7 @@ celery==5.4.0 django_celery_beat==2.6.0 django-celery-results==2.5.1 # file serve -whitenoise==6.6.0 +whitenoise==6.11.0 # fake data faker==25.0.0 # filters @@ -45,13 +45,13 @@ 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.6 # html validator lxml==6.0.0 # s3 @@ -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 diff --git a/apps/api/requirements/test.txt b/apps/api/requirements/test.txt index 66a1ff163..c31497535 100644 --- a/apps/api/requirements/test.txt +++ b/apps/api/requirements/test.txt @@ -1,6 +1,6 @@ -r base.txt # test framework -pytest==7.4.0 +pytest==9.0.2 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 \ No newline at end of file +requests==2.33.0 \ No newline at end of file diff --git a/apps/api/run_tests.py b/apps/api/run_tests.py index b92f9fe5b..886e8a041 100755 --- a/apps/api/run_tests.py +++ b/apps/api/run_tests.py @@ -1,4 +1,8 @@ #!/usr/bin/env python +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + import argparse import subprocess import sys diff --git a/apps/api/templates/emails/auth/forgot_password.html b/apps/api/templates/emails/auth/forgot_password.html index f673c1e63..29c9b4663 100644 --- a/apps/api/templates/emails/auth/forgot_password.html +++ b/apps/api/templates/emails/auth/forgot_password.html @@ -1,330 +1,306 @@ - - + + - - - - - Set a new password to your Plane account - - - - - - + + + Reset your Plane password + + - - - - - - + + + + + Reset your Plane password with this secure link. + + + + + + \ No newline at end of file diff --git a/apps/api/templates/emails/auth/magic_signin.html b/apps/api/templates/emails/auth/magic_signin.html index c32b399fb..a52700fa2 100644 --- a/apps/api/templates/emails/auth/magic_signin.html +++ b/apps/api/templates/emails/auth/magic_signin.html @@ -1,288 +1,265 @@ - - + + - - - - - Your unique Plane login code is code - - - - - - + + + Your Plane login code + + - - - - - - + + + + + Your Plane login code {{code}} is valid for 10 minutes. + + + + + + \ No newline at end of file diff --git a/apps/api/templates/emails/exports/analytics.html b/apps/api/templates/emails/exports/analytics.html index d2caa9d7a..f9a250d36 100644 --- a/apps/api/templates/emails/exports/analytics.html +++ b/apps/api/templates/emails/exports/analytics.html @@ -1,2 +1,215 @@ - - Hey there,
    Your requested data export from Plane Analytics is now ready. The information has been compiled into a CSV format for your convenience.
    Please find the attachment and download the CSV file. This file can easily be imported into any spreadsheet program for further analysis.
    If you require any assistance or have any questions, please do not hesitate to contact us.
    Thank you \ No newline at end of file + + + + + + Your Plane Analytics export is ready + + + + + + + + Your requested Plane Analytics data export is attached as a CSV file. + + + + + + + + \ No newline at end of file diff --git a/apps/api/templates/emails/invitations/project_invitation.html b/apps/api/templates/emails/invitations/project_invitation.html index 254408ac5..36aecd60d 100644 --- a/apps/api/templates/emails/invitations/project_invitation.html +++ b/apps/api/templates/emails/invitations/project_invitation.html @@ -124,7 +124,7 @@ ­
    -

    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 Discord or GitHub, and we will use your feedback to improve on our upcoming releases.

    +

    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 Forum or GitHub, and we will use your feedback to improve on our upcoming releases.

    ­ @@ -227,7 +227,7 @@ ­ - + ­ @@ -244,7 +244,7 @@ ­ - + ­ @@ -261,7 +261,7 @@ ­ - + ­ @@ -277,7 +277,7 @@ ­ - + ­ @@ -346,4 +346,4 @@ - \ No newline at end of file + diff --git a/apps/api/templates/emails/invitations/workspace_invitation.html b/apps/api/templates/emails/invitations/workspace_invitation.html index 619f03992..84ba654c1 100644 --- a/apps/api/templates/emails/invitations/workspace_invitation.html +++ b/apps/api/templates/emails/invitations/workspace_invitation.html @@ -1,219 +1,306 @@ - - + + - - - - - {{first_name}} has invited you to join them in {{workspace_name}} on Plane. - - - - - - + + + {{first_name}} invited you to {{workspace_name}} on Plane + + - - - - - - + + + + + {{first_name}} has invited you to join {{workspace_name}} on Plane. + + + + + + \ No newline at end of file diff --git a/apps/api/templates/emails/notifications/project_addition.html b/apps/api/templates/emails/notifications/project_addition.html index 59c7e0e4d..bc779f5ac 100644 --- a/apps/api/templates/emails/notifications/project_addition.html +++ b/apps/api/templates/emails/notifications/project_addition.html @@ -1,1591 +1,307 @@ - - + + - - - - - You are have been invited to a Plane project - - - - - - + - + - - - - +

    Plane Software, Inc.

    + + + + + + + + + + diff --git a/apps/api/templates/emails/notifications/webhook-deactivate.html b/apps/api/templates/emails/notifications/webhook-deactivate.html index 272271f96..44aca6720 100644 --- a/apps/api/templates/emails/notifications/webhook-deactivate.html +++ b/apps/api/templates/emails/notifications/webhook-deactivate.html @@ -155,7 +155,7 @@ ­
    -

    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 message, tweet, and conversation and update our public roadmap.

    +

    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 message, tweet, and conversation and update our public roadmap.

    ­ diff --git a/apps/api/templates/emails/user/user_activation.html b/apps/api/templates/emails/user/user_activation.html index a454d0a3c..8e0a692ad 100644 --- a/apps/api/templates/emails/user/user_activation.html +++ b/apps/api/templates/emails/user/user_activation.html @@ -959,8 +959,8 @@ nits, and anything else you find missing. We read every { + return Effect.gen(function* () { + const cookie = req.headers.cookie || ""; + if (!cookie) { + return yield* Effect.fail( + new PdfAuthenticationError({ + message: "Authentication required", + }) + ); + } + + const body = yield* Schema.decodeUnknown(PdfExportRequestBody)(req.body).pipe( + Effect.mapError( + (cause) => + new PdfValidationError({ + message: "Invalid request body", + cause, + }) + ) + ); + + return { + pageId: body.pageId, + workspaceSlug: body.workspaceSlug, + projectId: body.projectId, + title: body.title, + author: body.author, + subject: body.subject, + pageSize: body.pageSize, + pageOrientation: body.pageOrientation, + fileName: body.fileName, + noAssets: body.noAssets, + cookie, + requestId, + }; + }); + } + + /** + * Maps domain errors to HTTP responses + */ + private mapErrorToHttpResponse(error: unknown): { status: number; error: string } { + if (error && typeof error === "object" && "_tag" in error) { + const tag = (error as { _tag: string })._tag; + const message = (error as { message?: string }).message || "Unknown error"; + + switch (tag) { + case "PdfValidationError": + return { status: 400, error: message }; + case "PdfAuthenticationError": + return { status: 401, error: message }; + case "PdfContentFetchError": + return { + status: message.includes("not found") ? 404 : 502, + error: message, + }; + case "PdfTimeoutError": + return { status: 504, error: message }; + case "PdfGenerationError": + return { status: 500, error: message }; + case "PdfMetadataFetchError": + case "PdfImageProcessingError": + return { status: 502, error: message }; + default: + return { status: 500, error: message }; + } + } + return { status: 500, error: "Failed to generate PDF" }; + } + + @Post("/") + async exportToPdf(req: Request, res: Response) { + const requestId = crypto.randomUUID(); + + const effect = Effect.gen(this, function* () { + // Parse request + const input = yield* this.parseRequest(req, requestId); + + // Delegate to service + return yield* exportToPdf(input); + }).pipe( + // Log errors before catching them + Effect.tapError((error) => Effect.logError("PDF_EXPORT: Export failed", { requestId, error })), + // Map all tagged errors to HTTP responses + Effect.catchAll((error) => Effect.succeed(this.mapErrorToHttpResponse(error))), + // Handle unexpected defects + Effect.catchAllDefect((defect) => { + const appError = new AppError(Cause.pretty(Cause.die(defect)), { + context: { requestId, operation: "exportToPdf" }, + }); + logger.error("PDF_EXPORT: Unexpected failure", appError); + return Effect.succeed({ status: 500, error: "Failed to generate PDF" }); + }) + ); + + const result = await Effect.runPromise(Effect.provide(effect, PdfExportService.Default)); + + // Check if result is an error response + if ("error" in result && "status" in result) { + return res.status(result.status).json({ message: result.error }); + } + + // Success - send PDF + const { pdfBuffer, outputFileName } = result; + + // Sanitize filename for Content-Disposition header to prevent header injection + const sanitizedFileName = outputFileName + .replace(/["\\\r\n]/g, "") // Remove quotes, backslashes, and CRLF + .replace(/[^\x20-\x7E]/g, "_"); // Replace non-ASCII with underscore + + res.setHeader("Content-Type", "application/pdf"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${sanitizedFileName}"; filename*=UTF-8''${encodeURIComponent(outputFileName)}` + ); + res.setHeader("Content-Length", pdfBuffer.length); + return res.send(pdfBuffer); + } +} diff --git a/apps/live/src/env.ts b/apps/live/src/env.ts index 3c1a91ec9..c9b61bd43 100644 --- a/apps/live/src/env.ts +++ b/apps/live/src/env.ts @@ -1,4 +1,10 @@ -import * as dotenv from "@dotenvx/dotenvx"; +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import * as dotenv from "dotenv"; import { z } from "zod"; dotenv.config(); diff --git a/apps/live/src/extensions/database.ts b/apps/live/src/extensions/database.ts index 5c496f52c..becefc8e1 100644 --- a/apps/live/src/extensions/database.ts +++ b/apps/live/src/extensions/database.ts @@ -1,11 +1,18 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { Database as HocuspocusDatabase } from "@hocuspocus/extension-database"; -// utils +// plane imports import { getAllDocumentFormatsFromDocumentEditorBinaryData, getBinaryDataFromDocumentEditorHTMLString, } from "@plane/editor"; -// logger +import type { TDocumentPayload } from "@plane/types"; import { logger } from "@plane/logger"; +// lib import { AppError } from "@/lib/errors"; // services import { getPageService } from "@/services/page/handler"; @@ -36,15 +43,15 @@ const fetchDocument = async ({ context, documentName: pageId, instance }: FetchP convertedBinaryData, true ); - const payload = { + const payload: TDocumentPayload = { description_binary: contentBinaryEncoded, description_html: contentHTML, - description: contentJSON, + description_json: contentJSON, }; await service.updateDescriptionBinary(pageId, payload); } catch (e) { const error = new AppError(e); - logger.error("Failed to save binary after first convertion from html:", error); + logger.error("Failed to save binary after first conversion from html:", error); } return convertedBinaryData; } @@ -76,10 +83,10 @@ const storeDocument = async ({ true ); // create payload - const payload = { + const payload: TDocumentPayload = { description_binary: contentBinaryEncoded, description_html: contentHTML, - description: contentJSON, + description_json: contentJSON, }; await service.updateDescriptionBinary(pageId, payload); } catch (error) { diff --git a/apps/live/src/extensions/force-close-handler.ts b/apps/live/src/extensions/force-close-handler.ts index 19c06fe17..b13e08e84 100644 --- a/apps/live/src/extensions/force-close-handler.ts +++ b/apps/live/src/extensions/force-close-handler.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import type { Connection, Extension, Hocuspocus, onConfigurePayload } from "@hocuspocus/server"; import { logger } from "@plane/logger"; import { Redis } from "@/extensions/redis"; diff --git a/apps/live/src/extensions/index.ts b/apps/live/src/extensions/index.ts index fb53ab790..d55ca6e8e 100644 --- a/apps/live/src/extensions/index.ts +++ b/apps/live/src/extensions/index.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { Database } from "./database"; import { ForceCloseHandler } from "./force-close-handler"; import { Logger } from "./logger"; diff --git a/apps/live/src/extensions/logger.ts b/apps/live/src/extensions/logger.ts index 34a4f6a41..f670b66c1 100644 --- a/apps/live/src/extensions/logger.ts +++ b/apps/live/src/extensions/logger.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { Logger as HocuspocusLogger } from "@hocuspocus/extension-logger"; import { logger } from "@plane/logger"; diff --git a/apps/live/src/extensions/redis.ts b/apps/live/src/extensions/redis.ts index ece29671b..900a00137 100644 --- a/apps/live/src/extensions/redis.ts +++ b/apps/live/src/extensions/redis.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis"; import { OutgoingMessage } from "@hocuspocus/server"; import type { onConfigurePayload } from "@hocuspocus/server"; diff --git a/apps/live/src/extensions/title-sync.ts b/apps/live/src/extensions/title-sync.ts index ca3783f14..c86b74986 100644 --- a/apps/live/src/extensions/title-sync.ts +++ b/apps/live/src/extensions/title-sync.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + // hocuspocus import type { Extension, Hocuspocus, Document } from "@hocuspocus/server"; import { TiptapTransformer } from "@hocuspocus/transformer"; diff --git a/apps/live/src/extensions/title-update/debounce.ts b/apps/live/src/extensions/title-update/debounce.ts index e9adeb4a4..9de1ba44c 100644 --- a/apps/live/src/extensions/title-update/debounce.ts +++ b/apps/live/src/extensions/title-update/debounce.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { logger } from "@plane/logger"; /** diff --git a/apps/live/src/extensions/title-update/title-update-manager.ts b/apps/live/src/extensions/title-update/title-update-manager.ts index 8469ad4eb..5521c10ff 100644 --- a/apps/live/src/extensions/title-update/title-update-manager.ts +++ b/apps/live/src/extensions/title-update/title-update-manager.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { logger } from "@plane/logger"; import { AppError } from "@/lib/errors"; import { getPageService } from "@/services/page/handler"; diff --git a/apps/live/src/extensions/title-update/title-utils.ts b/apps/live/src/extensions/title-update/title-utils.ts new file mode 100644 index 000000000..ac2394865 --- /dev/null +++ b/apps/live/src/extensions/title-update/title-utils.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { sanitizeHTML } from "@plane/utils"; + +/** + * Utility function to extract text from HTML content + */ +export const extractTextFromHTML = (html: string): string => { + // Use sanitizeHTML to safely extract text and remove all HTML tags + // This is more secure than regex as it handles edge cases and prevents injection + // Note: sanitizeHTML trims whitespace, which is acceptable for title extraction + return sanitizeHTML(html) || ""; +}; diff --git a/apps/live/src/hocuspocus.ts b/apps/live/src/hocuspocus.ts index 1b3b07a7a..93ebf725e 100644 --- a/apps/live/src/hocuspocus.ts +++ b/apps/live/src/hocuspocus.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { Hocuspocus } from "@hocuspocus/server"; import { v4 as uuidv4 } from "uuid"; // env diff --git a/apps/live/src/instrument.ts b/apps/live/src/instrument.ts deleted file mode 100644 index a49016eb1..000000000 --- a/apps/live/src/instrument.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as Sentry from "@sentry/node"; -import { nodeProfilingIntegration } from "@sentry/profiling-node"; - -export const setupSentry = () => { - if (process.env.SENTRY_DSN) { - Sentry.init({ - dsn: process.env.SENTRY_DSN, - integrations: [Sentry.httpIntegration(), Sentry.expressIntegration(), nodeProfilingIntegration()], - tracesSampleRate: process.env.SENTRY_TRACES_SAMPLE_RATE ? parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE) : 0.5, - environment: process.env.SENTRY_ENVIRONMENT || "development", - release: process.env.APP_VERSION || "v1.0.0", - sendDefaultPii: true, - }); - } -}; diff --git a/apps/live/src/lib/auth-middleware.ts b/apps/live/src/lib/auth-middleware.ts index 8cdfc1b32..fcf06f82d 100644 --- a/apps/live/src/lib/auth-middleware.ts +++ b/apps/live/src/lib/auth-middleware.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import type { Request, Response, NextFunction } from "express"; import { logger } from "@plane/logger"; import { env } from "@/env"; diff --git a/apps/live/src/lib/auth.ts b/apps/live/src/lib/auth.ts index a1e82314a..02aa69ca4 100644 --- a/apps/live/src/lib/auth.ts +++ b/apps/live/src/lib/auth.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + // plane imports import type { IncomingHttpHeaders } from "http"; import type { TUserDetails } from "@plane/editor"; diff --git a/apps/live/src/lib/errors.ts b/apps/live/src/lib/errors.ts index a8b8270dc..4e2bc264c 100644 --- a/apps/live/src/lib/errors.ts +++ b/apps/live/src/lib/errors.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import type { AxiosError } from "axios"; /** diff --git a/apps/live/src/lib/pdf/colors.ts b/apps/live/src/lib/pdf/colors.ts new file mode 100644 index 000000000..1b220d723 --- /dev/null +++ b/apps/live/src/lib/pdf/colors.ts @@ -0,0 +1,231 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +/** + * PDF Export Color Constants + * + * These colors are mapped from the editor CSS variables and tailwind-config tokens + * to ensure PDF exports match the editor's appearance. + * + * Source mappings: + * - Editor colors: packages/editor/src/styles/variables.css + * - Tailwind tokens: packages/tailwind-config/variables.css + */ + +// Editor text colors (from variables.css :root) +export const EDITOR_TEXT_COLORS = { + gray: "#5c5e63", + peach: "#ff5b59", + pink: "#f65385", + orange: "#fd9038", + green: "#0fc27b", + "light-blue": "#17bee9", + "dark-blue": "#266df0", + purple: "#9162f9", +} as const; + +// Editor background colors - Light theme (from variables.css [data-theme*="light"]) +export const EDITOR_BACKGROUND_COLORS_LIGHT = { + gray: "#d6d6d8", + peach: "#ffd5d7", + pink: "#fdd4e3", + orange: "#ffe3cd", + green: "#c3f0de", + "light-blue": "#c5eff9", + "dark-blue": "#c9dafb", + purple: "#e3d8fd", +} as const; + +// Editor background colors - Dark theme (from variables.css [data-theme*="dark"]) +export const EDITOR_BACKGROUND_COLORS_DARK = { + gray: "#404144", + peach: "#593032", + pink: "#562e3d", + orange: "#583e2a", + green: "#1d4a3b", + "light-blue": "#1f495c", + "dark-blue": "#223558", + purple: "#3d325a", +} as const; + +// Use light theme colors by default for PDF exports +export const EDITOR_BACKGROUND_COLORS = EDITOR_BACKGROUND_COLORS_LIGHT; + +// Color key type +export type EditorColorKey = keyof typeof EDITOR_TEXT_COLORS; + +/** + * Maps a color key to its text color hex value + */ +export const getTextColorHex = (colorKey: string): string | null => { + if (colorKey in EDITOR_TEXT_COLORS) { + return EDITOR_TEXT_COLORS[colorKey as EditorColorKey]; + } + return null; +}; + +/** + * Maps a color key to its background color hex value + */ +export const getBackgroundColorHex = (colorKey: string): string | null => { + if (colorKey in EDITOR_BACKGROUND_COLORS) { + return EDITOR_BACKGROUND_COLORS[colorKey as EditorColorKey]; + } + return null; +}; + +/** + * Checks if a value is a CSS variable reference (e.g., "var(--editor-colors-gray-text)") + */ +export const isCssVariable = (value: string): boolean => { + return value.startsWith("var("); +}; + +/** + * Extracts the color key from a CSS variable reference + * e.g., "var(--editor-colors-gray-text)" -> "gray" + * e.g., "var(--editor-colors-light-blue-background)" -> "light-blue" + */ +export const extractColorKeyFromCssVariable = (cssVar: string): string | null => { + // Match patterns like: var(--editor-colors-{color}-text) or var(--editor-colors-{color}-background) + const match = cssVar.match(/var\(--editor-colors-([\w-]+)-(text|background)\)/); + if (match) { + return match[1]; + } + return null; +}; + +/** + * Resolves a color value to a hex color for PDF rendering + * Handles both direct hex values and CSS variable references + */ +export const resolveColorForPdf = (value: string | null | undefined, type: "text" | "background"): string | null => { + if (!value) return null; + + // If it's already a hex color, return it + if (value.startsWith("#")) { + return value; + } + + // If it's a CSS variable, extract the key and get the hex value + if (isCssVariable(value)) { + const colorKey = extractColorKeyFromCssVariable(value); + if (colorKey) { + return type === "text" ? getTextColorHex(colorKey) : getBackgroundColorHex(colorKey); + } + } + + // If it's just a color key (e.g., "gray", "peach"), get the hex value + if (type === "text") { + return getTextColorHex(value); + } + return getBackgroundColorHex(value); +}; + +// Semantic colors from tailwind-config (light theme) +// These are derived from the CSS variables in packages/tailwind-config/variables.css + +// Neutral colors (light theme) +export const NEUTRAL_COLORS = { + white: "#ffffff", + 100: "#fafafa", // oklch(0.9848 0.0003 230.66) ≈ #fafafa + 200: "#f5f5f5", // oklch(0.9696 0.0007 230.67) ≈ #f5f5f5 + 300: "#f0f0f0", // oklch(0.9543 0.001 230.67) ≈ #f0f0f0 + 400: "#ebebeb", // oklch(0.9389 0.0014 230.68) ≈ #ebebeb + 500: "#e5e5e5", // oklch(0.9235 0.001733 230.6853) ≈ #e5e5e5 + 600: "#d9d9d9", // oklch(0.8925 0.0024 230.7) ≈ #d9d9d9 + 700: "#cccccc", // oklch(0.8612 0.0032 230.71) ≈ #cccccc + 800: "#8c8c8c", // oklch(0.6668 0.0079 230.82) ≈ #8c8c8c + 900: "#7a7a7a", // oklch(0.6161 0.009153 230.867) ≈ #7a7a7a + 1000: "#636363", // oklch(0.5288 0.0083 230.88) ≈ #636363 + 1100: "#4d4d4d", // oklch(0.4377 0.0066 230.87) ≈ #4d4d4d + 1200: "#1f1f1f", // oklch(0.2378 0.0029 230.83) ≈ #1f1f1f + black: "#0f0f0f", // oklch(0.1472 0.0034 230.83) ≈ #0f0f0f +} as const; + +// Brand colors (light theme accent) +export const BRAND_COLORS = { + default: "#3f76ff", // oklch(0.4799 0.1158 242.91) - primary accent blue + 100: "#f5f8ff", + 200: "#e8f0ff", + 300: "#d1e1ff", + 400: "#b3d0ff", + 500: "#8ab8ff", + 600: "#5c9aff", + 700: "#3f76ff", + 900: "#2952b3", + 1000: "#1e3d80", + 1100: "#142b5c", + 1200: "#0d1f40", +} as const; + +// Semantic text colors +export const TEXT_COLORS = { + primary: NEUTRAL_COLORS[1200], // --txt-primary + secondary: NEUTRAL_COLORS[1100], // --txt-secondary + tertiary: NEUTRAL_COLORS[1000], // --txt-tertiary + placeholder: NEUTRAL_COLORS[900], // --txt-placeholder + disabled: NEUTRAL_COLORS[800], // --txt-disabled + accentPrimary: BRAND_COLORS.default, // --txt-accent-primary + linkPrimary: BRAND_COLORS.default, // --txt-link-primary +} as const; + +// Semantic background colors +export const BACKGROUND_COLORS = { + canvas: NEUTRAL_COLORS[300], // --bg-canvas + surface1: NEUTRAL_COLORS.white, // --bg-surface-1 + surface2: NEUTRAL_COLORS[100], // --bg-surface-2 + layer1: NEUTRAL_COLORS[200], // --bg-layer-1 + layer2: NEUTRAL_COLORS.white, // --bg-layer-2 + layer3: NEUTRAL_COLORS[300], // --bg-layer-3 + accentSubtle: "#f5f8ff", // --bg-accent-subtle (brand-100) +} as const; + +// Semantic border colors +export const BORDER_COLORS = { + subtle: NEUTRAL_COLORS[400], // --border-subtle + subtle1: NEUTRAL_COLORS[500], // --border-subtle-1 + strong: NEUTRAL_COLORS[600], // --border-strong + strong1: NEUTRAL_COLORS[700], // --border-strong-1 + accentStrong: BRAND_COLORS.default, // --border-accent-strong +} as const; + +// Code/inline code colors +export const CODE_COLORS = { + background: NEUTRAL_COLORS[200], // Similar to bg-layer-1 + text: "#dc2626", // Red for inline code text (matches editor) + blockText: NEUTRAL_COLORS[1200], // Regular text for code blocks +} as const; + +// Link colors +export const LINK_COLORS = { + primary: BRAND_COLORS.default, + hover: BRAND_COLORS[900], +} as const; + +// Mention colors (from pi-chat-editor mention styles: bg-accent-primary/20 text-accent-primary) +export const MENTION_COLORS = { + background: "#e0e9ff", // accent-primary with ~20% opacity on white + text: BRAND_COLORS.default, +} as const; + +// Success/Green colors +export const SUCCESS_COLORS = { + primary: "#10b981", + subtle: "#d1fae5", +} as const; + +// Warning/Amber colors +export const WARNING_COLORS = { + primary: "#f59e0b", + subtle: "#fef3c7", +} as const; + +// Danger/Red colors +export const DANGER_COLORS = { + primary: "#ef4444", + subtle: "#fee2e2", +} as const; diff --git a/apps/live/src/lib/pdf/icons.tsx b/apps/live/src/lib/pdf/icons.tsx new file mode 100644 index 000000000..92621f3a8 --- /dev/null +++ b/apps/live/src/lib/pdf/icons.tsx @@ -0,0 +1,232 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Circle, Path, Rect, Svg } from "@react-pdf/renderer"; + +type IconProps = { + size?: number; + color?: string; +}; + +// Lightbulb icon for callouts (default) +export const LightbulbIcon = ({ size = 16, color = "#ffffff" }: IconProps) => ( + + + +); + +// Document/file icon for page embeds +export const DocumentIcon = ({ size = 12, color = "#1e40af" }: IconProps) => ( + + + + + +); + +// Link icon for page links and external links +export const LinkIcon = ({ size = 12, color = "#2563eb" }: IconProps) => ( + + + + +); + +// Paperclip icon for attachments (default) +export const PaperclipIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + +); + +// Image icon for image attachments +export const ImageIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + + +); + +// Video icon for video attachments +export const VideoIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + +); + +// Music/audio icon +export const MusicIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + + +); + +// File-text icon for PDFs and documents +export const FileTextIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + +); + +// Table/spreadsheet icon +export const TableIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + +); + +// Presentation icon +export const PresentationIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + +); + +// Archive/zip icon +export const ArchiveIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + +); + +// Globe icon for external embeds (rich cards) +export const GlobeIcon = ({ size = 12, color = "#374151" }: IconProps) => ( + + + + +); + +// Clipboard icon for whiteboards +export const ClipboardIcon = ({ size = 12, color = "#6b7280" }: IconProps) => ( + + + + +); + +// Ruler/diagram icon for diagrams +export const DiagramIcon = ({ size = 12, color = "#6b7280" }: IconProps) => ( + + + + + +); + +// Work item / task icon +export const TaskIcon = ({ size = 14, color = "#374151" }: IconProps) => ( + + + + +); + +// Checkmark icon for checked task items +export const CheckIcon = ({ size = 10, color = "#ffffff" }: IconProps) => ( + + + +); + +// Helper to get file icon component based on file type +export const getFileIcon = (fileType: string, size = 16, color = "#374151") => { + if (fileType.startsWith("image/")) return ; + if (fileType.startsWith("video/")) return ; + if (fileType.startsWith("audio/")) return ; + if (fileType.includes("pdf")) return ; + if (fileType.includes("spreadsheet") || fileType.includes("excel")) return ; + if (fileType.includes("document") || fileType.includes("word")) return ; + if (fileType.includes("presentation") || fileType.includes("powerpoint")) + return ; + if (fileType.includes("zip") || fileType.includes("archive")) return ; + return ; +}; diff --git a/apps/live/src/lib/pdf/index.ts b/apps/live/src/lib/pdf/index.ts new file mode 100644 index 000000000..f3fe47831 --- /dev/null +++ b/apps/live/src/lib/pdf/index.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export { createPdfDocument, renderPlaneDocToPdfBlob, renderPlaneDocToPdfBuffer } from "./plane-pdf-exporter"; +export { createKeyGenerator, nodeRenderers, renderNode } from "./node-renderers"; +export { markRenderers, applyMarks } from "./mark-renderers"; +export { pdfStyles } from "./styles"; +export type { + KeyGenerator, + MarkRendererRegistry, + NodeRendererRegistry, + PDFExportMetadata, + PDFExportOptions, + PDFMarkRenderer, + PDFNodeRenderer, + PDFRenderContext, + PDFUserMention, + TipTapDocument, + TipTapMark, + TipTapNode, +} from "./types"; diff --git a/apps/live/src/lib/pdf/mark-renderers.ts b/apps/live/src/lib/pdf/mark-renderers.ts new file mode 100644 index 000000000..1f40c4e35 --- /dev/null +++ b/apps/live/src/lib/pdf/mark-renderers.ts @@ -0,0 +1,144 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Style } from "@react-pdf/types"; +import { + BACKGROUND_COLORS, + CODE_COLORS, + EDITOR_BACKGROUND_COLORS, + EDITOR_TEXT_COLORS, + LINK_COLORS, + resolveColorForPdf, +} from "./colors"; +import type { MarkRendererRegistry, TipTapMark } from "./types"; + +export const markRenderers: MarkRendererRegistry = { + bold: (_mark: TipTapMark, style: Style): Style => ({ + ...style, + fontWeight: "bold", + }), + + italic: (_mark: TipTapMark, style: Style): Style => ({ + ...style, + fontStyle: "italic", + }), + + underline: (_mark: TipTapMark, style: Style): Style => ({ + ...style, + textDecoration: "underline", + }), + + strike: (_mark: TipTapMark, style: Style): Style => ({ + ...style, + textDecoration: "line-through", + }), + + code: (_mark: TipTapMark, style: Style): Style => ({ + ...style, + fontFamily: "Courier", + fontSize: 10, + backgroundColor: BACKGROUND_COLORS.layer1, + color: CODE_COLORS.text, + }), + + link: (_mark: TipTapMark, style: Style): Style => ({ + ...style, + color: LINK_COLORS.primary, + textDecoration: "underline", + }), + + textStyle: (mark: TipTapMark, style: Style): Style => { + const attrs = mark.attrs || {}; + const newStyle: Style = { ...style }; + + if (attrs.color && typeof attrs.color === "string") { + newStyle.color = attrs.color; + } + + if (attrs.backgroundColor && typeof attrs.backgroundColor === "string") { + newStyle.backgroundColor = attrs.backgroundColor; + } + + return newStyle; + }, + + highlight: (mark: TipTapMark, style: Style): Style => { + const attrs = mark.attrs || {}; + return { + ...style, + backgroundColor: (attrs.color as string) || EDITOR_BACKGROUND_COLORS.purple, + }; + }, + + subscript: (_mark: TipTapMark, style: Style): Style => ({ + ...style, + fontSize: 8, + }), + + superscript: (_mark: TipTapMark, style: Style): Style => ({ + ...style, + fontSize: 8, + }), + + /** + * Custom color mark handler + * Handles the customColor extension which stores colors as data-text-color and data-background-color attributes + * The colors can be either: + * 1. Color keys like "gray", "peach", "pink", etc. (from COLORS_LIST) + * 2. Direct hex values for custom colors + * 3. CSS variable references like "var(--editor-colors-gray-text)" + */ + customColor: (mark: TipTapMark, style: Style): Style => { + const attrs = mark.attrs || {}; + const newStyle: Style = { ...style }; + + // Handle text color (stored in 'color' attribute) + const textColor = attrs.color as string | undefined; + if (textColor) { + const resolvedColor = resolveColorForPdf(textColor, "text"); + if (resolvedColor) { + newStyle.color = resolvedColor; + } else if (textColor.startsWith("#") || textColor.startsWith("rgb")) { + // Direct color value + newStyle.color = textColor; + } else if (textColor in EDITOR_TEXT_COLORS) { + // Color key lookup + newStyle.color = EDITOR_TEXT_COLORS[textColor as keyof typeof EDITOR_TEXT_COLORS]; + } + } + + // Handle background color (stored in 'backgroundColor' attribute) + const backgroundColor = attrs.backgroundColor as string | undefined; + if (backgroundColor) { + const resolvedColor = resolveColorForPdf(backgroundColor, "background"); + if (resolvedColor) { + newStyle.backgroundColor = resolvedColor; + } else if (backgroundColor.startsWith("#") || backgroundColor.startsWith("rgb")) { + // Direct color value + newStyle.backgroundColor = backgroundColor; + } else if (backgroundColor in EDITOR_BACKGROUND_COLORS) { + // Color key lookup + newStyle.backgroundColor = EDITOR_BACKGROUND_COLORS[backgroundColor as keyof typeof EDITOR_BACKGROUND_COLORS]; + } + } + + return newStyle; + }, +}; + +export const applyMarks = (marks: TipTapMark[] | undefined, baseStyle: Style = {}): Style => { + if (!marks || marks.length === 0) { + return baseStyle; + } + + return marks.reduce((style, mark) => { + const renderer = markRenderers[mark.type]; + if (renderer) { + return renderer(mark, style); + } + return style; + }, baseStyle); +}; diff --git a/apps/live/src/lib/pdf/node-renderers.tsx b/apps/live/src/lib/pdf/node-renderers.tsx new file mode 100644 index 000000000..003d21f55 --- /dev/null +++ b/apps/live/src/lib/pdf/node-renderers.tsx @@ -0,0 +1,444 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Image, Link, Text, View } from "@react-pdf/renderer"; +import type { Style } from "@react-pdf/types"; +import type { ReactElement } from "react"; +import { CORE_EXTENSIONS } from "@plane/editor"; +import { BACKGROUND_COLORS, EDITOR_BACKGROUND_COLORS, resolveColorForPdf, TEXT_COLORS } from "./colors"; +import { CheckIcon, ClipboardIcon, DocumentIcon, GlobeIcon, LightbulbIcon, LinkIcon } from "./icons"; +import { applyMarks } from "./mark-renderers"; +import { pdfStyles } from "./styles"; +import type { KeyGenerator, NodeRendererRegistry, PDFExportMetadata, PDFRenderContext, TipTapNode } from "./types"; + +const getCalloutIcon = (node: TipTapNode, color: string): ReactElement => { + const logoInUse = node.attrs?.["data-logo-in-use"] as string | undefined; + const iconName = node.attrs?.["data-icon-name"] as string | undefined; + const iconColor = (node.attrs?.["data-icon-color"] as string) || color; + + if (logoInUse === "emoji") { + const emojiUnicode = node.attrs?.["data-emoji-unicode"] as string | undefined; + if (emojiUnicode) { + return {emojiUnicode}; + } + } + + if (iconName) { + switch (iconName) { + case "FileText": + case "File": + return ; + case "Link": + return ; + case "Globe": + return ; + case "Clipboard": + return ; + case "CheckSquare": + case "Check": + return ; + case "Lightbulb": + default: + return ; + } + } + + return ; +}; + +export const createKeyGenerator = (): KeyGenerator => { + let counter = 0; + return () => `node-${counter++}`; +}; + +const renderTextWithMarks = (node: TipTapNode, getKey: KeyGenerator): ReactElement => { + const style = applyMarks(node.marks, {}); + const hasLink = node.marks?.find((m) => m.type === "link"); + + if (hasLink) { + const href = (hasLink.attrs?.href as string) || "#"; + return ( + + {node.text || ""} + + ); + } + + return ( + + {node.text || ""} + + ); +}; + +const getTextAlignStyle = (textAlign: string | null | undefined): Style => { + if (!textAlign) return {}; + return { + textAlign: textAlign as "left" | "right" | "center" | "justify", + }; +}; + +const getFlexAlignStyle = (textAlign: string | null | undefined): Style => { + if (!textAlign) return {}; + if (textAlign === "right") return { alignItems: "flex-end" }; + if (textAlign === "center") return { alignItems: "center" }; + return {}; +}; + +export const nodeRenderers: NodeRendererRegistry = { + doc: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + {children} + ), + + text: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => + renderTextWithMarks(node, ctx.getKey), + + paragraph: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const textAlign = node.attrs?.textAlign as string | null; + const background = node.attrs?.backgroundColor as string | undefined; + const alignStyle = getTextAlignStyle(textAlign); + const flexStyle = getFlexAlignStyle(textAlign); + const resolvedBgColor = + background && background !== "default" ? resolveColorForPdf(background, "background") : null; + const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {}; + + return ( + + {children} + + ); + }, + + heading: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const level = (node.attrs?.level as number) || 1; + const styleKey = `heading${level}` as keyof typeof pdfStyles; + const style = pdfStyles[styleKey] || pdfStyles.heading1; + const textAlign = node.attrs?.textAlign as string | null; + const alignStyle = getTextAlignStyle(textAlign); + const flexStyle = getFlexAlignStyle(textAlign); + + return ( + + {children} + + ); + }, + + blockquote: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + + {children} + + ), + + codeBlock: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const codeContent = node.content?.map((c) => c.text || "").join("") || ""; + return ( + + {codeContent} + + ); + }, + + bulletList: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const nestingLevel = (node.attrs?._nestingLevel as number) || 0; + const indentStyle = nestingLevel > 0 ? { marginLeft: 18 } : {}; + return ( + + {children} + + ); + }, + + orderedList: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const nestingLevel = (node.attrs?._nestingLevel as number) || 0; + const indentStyle = nestingLevel > 0 ? { marginLeft: 18 } : {}; + return ( + + {children} + + ); + }, + + listItem: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const isOrdered = node.attrs?._parentType === "orderedList"; + const index = (node.attrs?._listItemIndex as number) || 0; + + const bullet = isOrdered ? `${index}.` : "•"; + + const textAlign = node.attrs?._textAlign as string | null; + const flexStyle = getFlexAlignStyle(textAlign); + + return ( + + + {bullet} + + {children} + + ); + }, + + taskList: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + + {children} + + ), + + taskItem: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const checked = node.attrs?.checked === true; + return ( + + + {checked && } + + {children} + + ); + }, + + table: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + + {children} + + ), + + tableRow: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const isHeader = node.attrs?._isHeader === true; + return ( + + {children} + + ); + }, + + tableHeader: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const colwidth = node.attrs?.colwidth as number[] | undefined; + const background = node.attrs?.background as string | undefined; + const width = colwidth?.[0]; + const widthStyle = width ? { width, flex: undefined } : {}; + const resolvedBgColor = background ? resolveColorForPdf(background, "background") : null; + const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {}; + + return ( + + {children} + + ); + }, + + tableCell: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const colwidth = node.attrs?.colwidth as number[] | undefined; + const background = node.attrs?.background as string | undefined; + const width = colwidth?.[0]; + const widthStyle = width ? { width, flex: undefined } : {}; + const resolvedBgColor = background ? resolveColorForPdf(background, "background") : null; + const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {}; + + return ( + + {children} + + ); + }, + + horizontalRule: (_node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + + ), + + hardBreak: (_node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + {"\n"} + ), + + image: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + if (ctx.metadata?.noAssets) { + return ; + } + + const src = (node.attrs?.src as string) || ""; + const width = node.attrs?.width as number | undefined; + const alignment = (node.attrs?.alignment as string) || "left"; + + if (!src) { + return ; + } + + const alignmentStyle = + alignment === "center" + ? { alignItems: "center" as const } + : alignment === "right" + ? { alignItems: "flex-end" as const } + : { alignItems: "flex-start" as const }; + + return ( + + + + ); + }, + + imageComponent: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + if (ctx.metadata?.noAssets) { + return ; + } + + const assetId = (node.attrs?.src as string) || ""; + const rawWidth = node.attrs?.width; + const width = typeof rawWidth === "string" ? parseInt(rawWidth, 10) : (rawWidth as number | undefined); + const alignment = (node.attrs?.alignment as string) || "left"; + + if (!assetId) { + return ; + } + + let resolvedSrc = assetId; + if (ctx.metadata?.resolvedImageUrls && ctx.metadata.resolvedImageUrls[assetId]) { + resolvedSrc = ctx.metadata.resolvedImageUrls[assetId]; + } + + const alignmentStyle = + alignment === "center" + ? { alignItems: "center" as const } + : alignment === "right" + ? { alignItems: "flex-end" as const } + : { alignItems: "flex-start" as const }; + + if (!resolvedSrc.startsWith("http") && !resolvedSrc.startsWith("data:")) { + return ( + + [Image: {assetId.slice(0, 8)}...] + + ); + } + + const imageStyle = width && !isNaN(width) ? { width, maxHeight: 500 } : { maxWidth: 400, maxHeight: 500 }; + + return ( + + + + ); + }, + + calloutComponent: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const backgroundKey = (node.attrs?.["data-background"] as string) || "gray"; + const backgroundColor = + EDITOR_BACKGROUND_COLORS[backgroundKey as keyof typeof EDITOR_BACKGROUND_COLORS] || BACKGROUND_COLORS.layer3; + + return ( + + {getCalloutIcon(node, TEXT_COLORS.primary)} + {children} + + ); + }, + + mention: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const id = (node.attrs?.id as string) || ""; + const entityIdentifier = (node.attrs?.entity_identifier as string) || ""; + const entityName = (node.attrs?.entity_name as string) || ""; + + let displayText = entityName || id || entityIdentifier; + + if (ctx.metadata && (entityName === "user_mention" || entityName === "user")) { + const userMention = ctx.metadata.userMentions?.find((u) => u.id === entityIdentifier || u.id === id); + if (userMention) { + displayText = userMention.display_name; + } + } + + return ( + + @{displayText} + + ); + }, +}; + +type InternalRenderContext = { + parentType?: string; + nestingLevel: number; + listItemIndex: number; + textAlign?: string | null; + pdfContext: PDFRenderContext; +}; + +const renderNodeWithContext = (node: TipTapNode, context: InternalRenderContext): ReactElement => { + const { parentType, nestingLevel, listItemIndex, textAlign, pdfContext } = context; + + const isListContainer = node.type === CORE_EXTENSIONS.BULLET_LIST || node.type === CORE_EXTENSIONS.ORDERED_LIST; + + let childTextAlign = textAlign; + if (node.type === CORE_EXTENSIONS.PARAGRAPH && node.attrs?.textAlign) { + childTextAlign = node.attrs.textAlign as string; + } + + const nodeWithContext = { + ...node, + attrs: { + ...node.attrs, + _parentType: parentType, + _nestingLevel: nestingLevel, + _listItemIndex: listItemIndex, + _textAlign: childTextAlign, + _isHeader: node.content?.some((child) => child.type === CORE_EXTENSIONS.TABLE_HEADER), + }, + }; + + let childNestingLevel = nestingLevel; + if (isListContainer && parentType === CORE_EXTENSIONS.LIST_ITEM) { + childNestingLevel = nestingLevel + 1; + } + + let currentListItemIndex = 0; + const children: ReactElement[] = + node.content?.map((child) => { + const childContext: InternalRenderContext = { + parentType: node.type, + nestingLevel: childNestingLevel, + listItemIndex: 0, + textAlign: childTextAlign, + pdfContext, + }; + + if (isListContainer && child.type === CORE_EXTENSIONS.LIST_ITEM) { + currentListItemIndex++; + childContext.listItemIndex = currentListItemIndex; + } + + return renderNodeWithContext(child, childContext); + }) || []; + + const renderer = nodeRenderers[node.type]; + if (renderer) { + return renderer(nodeWithContext, children, pdfContext); + } + + if (children.length > 0) { + return {children}; + } + + return ; +}; + +export const renderNode = ( + node: TipTapNode, + parentType?: string, + _index?: number, + metadata?: PDFExportMetadata, + getKey?: KeyGenerator +): ReactElement => { + const keyGen = getKey ?? createKeyGenerator(); + + return renderNodeWithContext(node, { + parentType, + nestingLevel: 0, + listItemIndex: 0, + pdfContext: { getKey: keyGen, metadata }, + }); +}; diff --git a/apps/live/src/lib/pdf/plane-pdf-exporter.tsx b/apps/live/src/lib/pdf/plane-pdf-exporter.tsx new file mode 100644 index 000000000..f6c6b599c --- /dev/null +++ b/apps/live/src/lib/pdf/plane-pdf-exporter.tsx @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { createRequire } from "module"; +import path from "path"; +import { Document, Font, Page, pdf, Text } from "@react-pdf/renderer"; +import { createKeyGenerator, renderNode } from "./node-renderers"; +import { pdfStyles } from "./styles"; +import type { PDFExportOptions, TipTapDocument } from "./types"; + +// Use createRequire for ESM compatibility to resolve font file paths +const require = createRequire(import.meta.url); + +// Resolve local font file paths from @fontsource/inter package +const interFontDir = path.dirname(require.resolve("@fontsource/inter/package.json")); + +Font.register({ + family: "Inter", + fonts: [ + { + src: path.join(interFontDir, "files/inter-latin-400-normal.woff"), + fontWeight: 400, + }, + { + src: path.join(interFontDir, "files/inter-latin-400-italic.woff"), + fontWeight: 400, + fontStyle: "italic", + }, + { + src: path.join(interFontDir, "files/inter-latin-600-normal.woff"), + fontWeight: 600, + }, + { + src: path.join(interFontDir, "files/inter-latin-600-italic.woff"), + fontWeight: 600, + fontStyle: "italic", + }, + { + src: path.join(interFontDir, "files/inter-latin-700-normal.woff"), + fontWeight: 700, + }, + { + src: path.join(interFontDir, "files/inter-latin-700-italic.woff"), + fontWeight: 700, + fontStyle: "italic", + }, + ], +}); + +export const createPdfDocument = (doc: TipTapDocument, options: PDFExportOptions = {}) => { + const { title, author, subject, pageSize = "A4", pageOrientation = "portrait", metadata, noAssets } = options; + + // Merge noAssets into metadata for use in node renderers + const mergedMetadata = { ...metadata, noAssets }; + + const content = doc.content || []; + const getKey = createKeyGenerator(); + const renderedContent = content.map((node, index) => renderNode(node, "doc", index, mergedMetadata, getKey)); + + return ( + + + {title && {title}} + {renderedContent} + + + ); +}; + +export const renderPlaneDocToPdfBuffer = async ( + doc: TipTapDocument, + options: PDFExportOptions = {} +): Promise => { + const pdfDocument = createPdfDocument(doc, options); + const pdfInstance = pdf(pdfDocument); + const blob = await pdfInstance.toBlob(); + const arrayBuffer = await blob.arrayBuffer(); + return Buffer.from(arrayBuffer); +}; + +export const renderPlaneDocToPdfBlob = async (doc: TipTapDocument, options: PDFExportOptions = {}): Promise => { + const pdfDocument = createPdfDocument(doc, options); + const pdfInstance = pdf(pdfDocument); + return await pdfInstance.toBlob(); +}; diff --git a/apps/live/src/lib/pdf/styles.ts b/apps/live/src/lib/pdf/styles.ts new file mode 100644 index 000000000..b55a156d0 --- /dev/null +++ b/apps/live/src/lib/pdf/styles.ts @@ -0,0 +1,250 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { StyleSheet } from "@react-pdf/renderer"; +import { + BACKGROUND_COLORS, + BORDER_COLORS, + BRAND_COLORS, + CODE_COLORS, + LINK_COLORS, + MENTION_COLORS, + TEXT_COLORS, +} from "./colors"; + +export const pdfStyles = StyleSheet.create({ + page: { + padding: 40, + fontFamily: "Inter", + fontSize: 11, + lineHeight: 1.6, + color: TEXT_COLORS.primary, + }, + title: { + fontSize: 24, + fontWeight: 600, + marginBottom: 20, + color: TEXT_COLORS.primary, + }, + heading1: { + fontSize: 20, + fontWeight: 600, + marginTop: 16, + marginBottom: 8, + color: TEXT_COLORS.primary, + }, + heading2: { + fontSize: 16, + fontWeight: 600, + marginTop: 14, + marginBottom: 6, + color: TEXT_COLORS.primary, + }, + heading3: { + fontSize: 14, + fontWeight: 600, + marginTop: 12, + marginBottom: 4, + color: TEXT_COLORS.primary, + }, + heading4: { + fontSize: 12, + fontWeight: 600, + marginTop: 10, + marginBottom: 4, + color: TEXT_COLORS.secondary, + }, + heading5: { + fontSize: 11, + fontWeight: 600, + marginTop: 8, + marginBottom: 4, + color: TEXT_COLORS.secondary, + }, + heading6: { + fontSize: 10, + fontWeight: 600, + marginTop: 6, + marginBottom: 4, + color: TEXT_COLORS.tertiary, + }, + paragraph: { + marginBottom: 0, + }, + paragraphWrapper: { + marginBottom: 8, + }, + blockquote: { + borderLeftWidth: 3, + borderLeftColor: BORDER_COLORS.strong, // Matches .ProseMirror blockquote border-strong + paddingLeft: 12, + marginLeft: 0, + marginVertical: 8, + fontStyle: "normal", // Matches editor: font-style: normal + fontWeight: 400, // Matches editor: font-weight: 400 + color: TEXT_COLORS.primary, + breakInside: "avoid", + }, + codeBlock: { + backgroundColor: BACKGROUND_COLORS.layer1, // bg-layer-1 equivalent + padding: 12, + borderRadius: 4, + fontFamily: "Courier", + fontSize: 10, + marginVertical: 8, + color: TEXT_COLORS.primary, + breakInside: "avoid", + }, + codeInline: { + backgroundColor: BACKGROUND_COLORS.layer1, + padding: 2, + paddingHorizontal: 4, + borderRadius: 2, + fontFamily: "Courier", + fontSize: 10, + color: CODE_COLORS.text, // Red for inline code + }, + bulletList: { + marginVertical: 8, + paddingLeft: 0, + }, + orderedList: { + marginVertical: 8, + paddingLeft: 0, + }, + listItem: { + display: "flex", + flexDirection: "row", + gap: 6, + marginBottom: 4, + paddingRight: 10, + breakInside: "avoid", + }, + listItemBullet: {}, + listItemContent: { + flex: 1, + }, + taskList: { + marginVertical: 8, + }, + taskItem: { + display: "flex", + flexDirection: "row", + gap: 6, + marginBottom: 4, + alignItems: "flex-start", + paddingRight: 10, + breakInside: "avoid", + }, + taskCheckbox: { + width: 12, + height: 12, + borderWidth: 1, + borderColor: BORDER_COLORS.strong, // Matches editor: border-strong + borderRadius: 2, + marginTop: 2, + alignItems: "center", + justifyContent: "center", + }, + taskCheckboxChecked: { + backgroundColor: BRAND_COLORS.default, // --background-color-accent-primary + borderColor: BRAND_COLORS.default, // --border-color-accent-strong + }, + table: { + marginVertical: 8, + borderWidth: 1, + borderColor: BORDER_COLORS.subtle1, // border-subtle-1 + }, + tableRow: { + flexDirection: "row", + borderBottomWidth: 1, + borderBottomColor: BORDER_COLORS.subtle1, + breakInside: "avoid", + }, + tableHeaderRow: { + backgroundColor: BACKGROUND_COLORS.surface2, // Slightly different from white + flexDirection: "row", + borderBottomWidth: 1, + borderBottomColor: BORDER_COLORS.subtle1, + }, + tableCell: { + padding: 8, + borderRightWidth: 1, + borderRightColor: BORDER_COLORS.subtle1, + flex: 1, + }, + tableHeaderCell: { + padding: 8, + borderRightWidth: 1, + borderRightColor: BORDER_COLORS.subtle1, + flex: 1, + fontWeight: "bold", + }, + horizontalRule: { + borderBottomWidth: 1, + borderBottomColor: BORDER_COLORS.subtle1, // Matches div[data-type="horizontalRule"] border-subtle-1 + marginVertical: 16, + }, + image: { + maxWidth: "100%", + marginVertical: 8, + }, + imagePlaceholder: { + backgroundColor: BACKGROUND_COLORS.layer1, + padding: 16, + borderRadius: 4, + marginVertical: 8, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: BORDER_COLORS.subtle, + borderStyle: "dashed", + }, + imagePlaceholderText: { + color: TEXT_COLORS.tertiary, + fontSize: 10, + }, + callout: { + backgroundColor: BACKGROUND_COLORS.layer3, // bg-layer-3 (default callout background) + padding: 12, + borderRadius: 6, + marginVertical: 8, + flexDirection: "row", + alignItems: "flex-start", + breakInside: "avoid", + }, + calloutIconContainer: { + marginRight: 10, + marginTop: 2, + }, + calloutContent: { + flex: 1, + color: TEXT_COLORS.primary, // text-primary + }, + mention: { + backgroundColor: MENTION_COLORS.background, // bg-accent-primary/20 equivalent + color: MENTION_COLORS.text, // text-accent-primary + padding: 2, + paddingHorizontal: 4, + borderRadius: 2, + }, + link: { + color: LINK_COLORS.primary, // --txt-link-primary + textDecoration: "underline", + }, + bold: { + fontWeight: "bold", + }, + italic: { + fontStyle: "italic", + }, + underline: { + textDecoration: "underline", + }, + strike: { + textDecoration: "line-through", + }, +}); diff --git a/apps/live/src/lib/pdf/types.ts b/apps/live/src/lib/pdf/types.ts new file mode 100644 index 000000000..0578a49c5 --- /dev/null +++ b/apps/live/src/lib/pdf/types.ts @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Style } from "@react-pdf/types"; + +export type TipTapMark = { + type: string; + attrs?: Record; +}; + +export type TipTapNode = { + type: string; + attrs?: Record; + content?: TipTapNode[]; + text?: string; + marks?: TipTapMark[]; +}; + +export type TipTapDocument = { + type: "doc"; + content?: TipTapNode[]; +}; + +export type KeyGenerator = () => string; + +export type PDFRenderContext = { + getKey: KeyGenerator; + metadata?: PDFExportMetadata; +}; + +export type PDFNodeRenderer = ( + node: TipTapNode, + children: React.ReactElement[], + context: PDFRenderContext +) => React.ReactElement; + +export type PDFMarkRenderer = (mark: TipTapMark, currentStyle: Style) => Style; + +export type NodeRendererRegistry = Record; + +export type MarkRendererRegistry = Record; + +export type PDFExportOptions = { + title?: string; + author?: string; + subject?: string; + pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID"; + pageOrientation?: "portrait" | "landscape"; + metadata?: PDFExportMetadata; + /** When true, images and other assets are excluded from the PDF */ + noAssets?: boolean; +}; + +/** + * Metadata for resolving entity references in PDF export + */ +export type PDFExportMetadata = { + /** User mentions (user_mention in mention node) */ + userMentions?: PDFUserMention[]; + /** Resolved image URLs: Map of asset ID to presigned URL */ + resolvedImageUrls?: Record; + /** When true, images and other assets are excluded from the PDF */ + noAssets?: boolean; +}; + +export type PDFUserMention = { + id: string; + display_name: string; + avatar_url?: string; +}; diff --git a/apps/live/src/lib/stateless.ts b/apps/live/src/lib/stateless.ts index 1692164d2..d59f8fe99 100644 --- a/apps/live/src/lib/stateless.ts +++ b/apps/live/src/lib/stateless.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import type { onStatelessPayload } from "@hocuspocus/server"; import { DocumentCollaborativeEvents } from "@plane/editor/lib"; import type { TDocumentEventsServer } from "@plane/editor/lib"; diff --git a/apps/live/src/redis.ts b/apps/live/src/redis.ts index aac0eb712..f23374a79 100644 --- a/apps/live/src/redis.ts +++ b/apps/live/src/redis.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import Redis from "ioredis"; import { logger } from "@plane/logger"; import { env } from "./env"; diff --git a/apps/live/src/schema/pdf-export.ts b/apps/live/src/schema/pdf-export.ts new file mode 100644 index 000000000..e3085eefa --- /dev/null +++ b/apps/live/src/schema/pdf-export.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Schema } from "effect"; + +export const PdfExportRequestBody = Schema.Struct({ + pageId: Schema.NonEmptyTrimmedString, + workspaceSlug: Schema.NonEmptyTrimmedString, + projectId: Schema.optional(Schema.NonEmptyTrimmedString), + title: Schema.optional(Schema.String), + author: Schema.optional(Schema.String), + subject: Schema.optional(Schema.String), + pageSize: Schema.optional(Schema.Literal("A4", "A3", "A2", "LETTER", "LEGAL", "TABLOID")), + pageOrientation: Schema.optional(Schema.Literal("portrait", "landscape")), + fileName: Schema.optional(Schema.String), + noAssets: Schema.optional(Schema.Boolean), +}); + +export type TPdfExportRequestBody = Schema.Schema.Type; + +export class PdfValidationError extends Schema.TaggedError()("PdfValidationError", { + message: Schema.NonEmptyTrimmedString, + cause: Schema.optional(Schema.Unknown), +}) {} + +export class PdfAuthenticationError extends Schema.TaggedError()("PdfAuthenticationError", { + message: Schema.NonEmptyTrimmedString, +}) {} + +export class PdfContentFetchError extends Schema.TaggedError()("PdfContentFetchError", { + message: Schema.NonEmptyTrimmedString, + cause: Schema.optional(Schema.Unknown), +}) {} + +export class PdfMetadataFetchError extends Schema.TaggedError()("PdfMetadataFetchError", { + message: Schema.NonEmptyTrimmedString, + source: Schema.Literal("user-mentions"), + cause: Schema.optional(Schema.Unknown), +}) {} + +export class PdfImageProcessingError extends Schema.TaggedError()("PdfImageProcessingError", { + message: Schema.NonEmptyTrimmedString, + assetId: Schema.NonEmptyTrimmedString, + cause: Schema.optional(Schema.Unknown), +}) {} + +export class PdfGenerationError extends Schema.TaggedError()("PdfGenerationError", { + message: Schema.NonEmptyTrimmedString, + cause: Schema.optional(Schema.Unknown), +}) {} + +export class PdfTimeoutError extends Schema.TaggedError()("PdfTimeoutError", { + message: Schema.NonEmptyTrimmedString, + operation: Schema.NonEmptyTrimmedString, +}) {} + +export type PdfExportError = + | PdfValidationError + | PdfAuthenticationError + | PdfContentFetchError + | PdfMetadataFetchError + | PdfImageProcessingError + | PdfGenerationError + | PdfTimeoutError; diff --git a/apps/live/src/server.ts b/apps/live/src/server.ts index 59e5ec01b..9a3906bf7 100644 --- a/apps/live/src/server.ts +++ b/apps/live/src/server.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import type { Server as HttpServer } from "http"; import type { Hocuspocus } from "@hocuspocus/server"; import compression from "compression"; diff --git a/apps/live/src/services/api.service.ts b/apps/live/src/services/api.service.ts index 68eb52b38..3834bbd6b 100644 --- a/apps/live/src/services/api.service.ts +++ b/apps/live/src/services/api.service.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import type { AxiosInstance } from "axios"; import axios from "axios"; import { env } from "@/env"; diff --git a/apps/live/src/services/page/core.service.ts b/apps/live/src/services/page/core.service.ts index 04a064091..c3f3242cf 100644 --- a/apps/live/src/services/page/core.service.ts +++ b/apps/live/src/services/page/core.service.ts @@ -1,13 +1,19 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { logger } from "@plane/logger"; -import type { TPage } from "@plane/types"; +import type { TDocumentPayload, TPage } from "@plane/types"; // services import { AppError } from "@/lib/errors"; import { APIService } from "../api.service"; -export type TPageDescriptionPayload = { - description_binary: string; - description_html: string; - description: object; +export type TUserMention = { + id: string; + display_name: string; + avatar_url?: string; }; export abstract class PageCoreService extends APIService { @@ -18,35 +24,41 @@ export abstract class PageCoreService extends APIService { } async fetchDetails(pageId: string): Promise { - return this.get(`${this.basePath}/pages/${pageId}/`, { - headers: this.getHeader(), - }) - .then((response) => response?.data) - .catch((error) => { - const appError = new AppError(error, { - context: { operation: "fetchDetails", pageId }, - }); - logger.error("Failed to fetch page details", appError); - throw appError; + try { + const response = await this.get(`${this.basePath}/pages/${pageId}/`, { + headers: this.getHeader(), }); + return response?.data as TPage; + } catch (error) { + const appError = new AppError(error, { + context: { operation: "fetchDetails", pageId }, + }); + logger.error("Failed to fetch page details", appError); + throw appError; + } } - async fetchDescriptionBinary(pageId: string): Promise { - return this.get(`${this.basePath}/pages/${pageId}/description/`, { - headers: { - ...this.getHeader(), - "Content-Type": "application/octet-stream", - }, - responseType: "arraybuffer", - }) - .then((response) => response?.data) - .catch((error) => { - const appError = new AppError(error, { - context: { operation: "fetchDescriptionBinary", pageId }, - }); - logger.error("Failed to fetch page description binary", appError); - throw appError; + async fetchDescriptionBinary(pageId: string): Promise { + try { + const response = await this.get(`${this.basePath}/pages/${pageId}/description/`, { + headers: { + ...this.getHeader(), + "Content-Type": "application/octet-stream", + }, + responseType: "arraybuffer", }); + const data = response?.data; + if (!Buffer.isBuffer(data)) { + throw new Error("Expected response to be a Buffer"); + } + return data; + } catch (error) { + const appError = new AppError(error, { + context: { operation: "fetchDescriptionBinary", pageId }, + }); + logger.error("Failed to fetch page description binary", appError); + throw appError; + } } /** @@ -103,17 +115,113 @@ export abstract class PageCoreService extends APIService { } } - async updateDescriptionBinary(pageId: string, data: TPageDescriptionPayload): Promise { - return this.patch(`${this.basePath}/pages/${pageId}/description/`, data, { - headers: this.getHeader(), - }) - .then((response) => response?.data) - .catch((error) => { - const appError = new AppError(error, { - context: { operation: "updateDescriptionBinary", pageId }, - }); - logger.error("Failed to update page description binary", appError); - throw appError; + async updateDescriptionBinary(pageId: string, data: TDocumentPayload): Promise { + try { + const response = await this.patch(`${this.basePath}/pages/${pageId}/description/`, data, { + headers: this.getHeader(), }); + return response?.data as unknown; + } catch (error) { + const appError = new AppError(error, { + context: { operation: "updateDescriptionBinary", pageId }, + }); + logger.error("Failed to update page description binary", appError); + throw appError; + } + } + + /** + * Fetches user mentions for a page + * @param pageId - The page ID + * @returns Array of user mentions + */ + async fetchUserMentions(pageId: string): Promise { + try { + const response = await this.get(`${this.basePath}/pages/${pageId}/mentions/`, { + headers: this.getHeader(), + params: { + mention_type: "user_mention", + }, + }); + return (response?.data as TUserMention[]) ?? []; + } catch (error) { + const appError = new AppError(error, { + context: { operation: "fetchUserMentions", pageId }, + }); + logger.error("Failed to fetch user mentions", appError); + throw appError; + } + } + + /** + * Resolves an image asset ID to its actual URL by following the 302 redirect + * @param workspaceSlug - The workspace slug + * @param assetId - The asset UUID + * @param projectId - Optional project ID for project-specific assets + * @returns The resolved image URL (presigned S3 URL) + */ + async resolveImageAssetUrl( + workspaceSlug: string, + assetId: string, + projectId?: string | null + ): Promise { + const path = projectId + ? `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/?disposition=inline` + : `/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/?disposition=inline`; + + try { + const response = await this.get(path, { + headers: this.getHeader(), + maxRedirects: 0, + validateStatus: (status: number) => status >= 200 && status < 400, + }); + // If we get a 302, the Location header contains the presigned URL + if (response.status === 302 || response.status === 301) { + return response.headers?.location || null; + } + return null; + } catch (error) { + // Axios throws on 3xx when maxRedirects is 0, so we need to handle the redirect from the error + if ((error as any).response?.status === 302 || (error as any).response?.status === 301) { + return (error as any).response.headers?.location || null; + } + logger.error("Failed to resolve image asset URL", { + assetId, + workspaceSlug, + error: (error as any).message, + }); + return null; + } + } + + /** + * Resolves multiple image asset IDs to their actual URLs + * @param workspaceSlug - The workspace slug + * @param assetIds - Array of asset UUIDs + * @param projectId - Optional project ID for project-specific assets + * @returns Map of assetId to resolved URL + */ + async resolveImageAssetUrls( + workspaceSlug: string, + assetIds: string[], + projectId?: string | null + ): Promise> { + const urlMap = new Map(); + + // Resolve all asset URLs in parallel + const results = await Promise.allSettled( + assetIds.map(async (assetId) => { + const url = await this.resolveImageAssetUrl(workspaceSlug, assetId, projectId); + return { assetId, url }; + }) + ); + + for (const result of results) { + if (result.status === "fulfilled" && result.value.url) { + urlMap.set(result.value.assetId, result.value.url); + } + } + + return urlMap; } } diff --git a/apps/live/src/services/page/extended.service.ts b/apps/live/src/services/page/extended.service.ts index 29ef316db..2b076efac 100644 --- a/apps/live/src/services/page/extended.service.ts +++ b/apps/live/src/services/page/extended.service.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { PageCoreService } from "./core.service"; /** diff --git a/apps/live/src/services/page/handler.ts b/apps/live/src/services/page/handler.ts index 9b2f5adac..2bfd0b1dd 100644 --- a/apps/live/src/services/page/handler.ts +++ b/apps/live/src/services/page/handler.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { AppError } from "@/lib/errors"; import type { HocusPocusServerContext, TDocumentTypes } from "@/types"; // services diff --git a/apps/live/src/services/page/project-page.service.ts b/apps/live/src/services/page/project-page.service.ts index 89a115627..d89ab0aa7 100644 --- a/apps/live/src/services/page/project-page.service.ts +++ b/apps/live/src/services/page/project-page.service.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { AppError } from "@/lib/errors"; import { PageService } from "./extended.service"; diff --git a/apps/live/src/services/pdf-export/effect-utils.ts b/apps/live/src/services/pdf-export/effect-utils.ts new file mode 100644 index 000000000..18f40b089 --- /dev/null +++ b/apps/live/src/services/pdf-export/effect-utils.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Effect, Duration, Schedule, pipe } from "effect"; +import { PdfTimeoutError } from "@/schema/pdf-export"; + +/** + * Wraps an effect with timeout and exponential backoff retry logic. + * Preserves the environment type R for proper dependency injection. + */ +export const withTimeoutAndRetry = + (operation: string, { timeoutMs = 5000, maxRetries = 2 }: { timeoutMs?: number; maxRetries?: number } = {}) => + (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.timeoutFail({ + duration: Duration.millis(timeoutMs), + onTimeout: () => + new PdfTimeoutError({ + message: `Operation "${operation}" timed out after ${timeoutMs}ms`, + operation, + }), + }), + Effect.retry( + pipe( + Schedule.exponential(Duration.millis(200)), + Schedule.compose(Schedule.recurs(maxRetries)), + Schedule.tapInput((error: E | PdfTimeoutError) => + Effect.logWarning("PDF_EXPORT: Retrying operation", { operation, error }) + ) + ) + ) + ); + +/** + * Recovers from any error with a default fallback value. + * Logs the error before recovering. + */ +export const recoverWithDefault = + (fallback: A) => + (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.tapError((error) => Effect.logWarning("PDF_EXPORT: Operation failed, using fallback", { error })), + Effect.catchAll(() => Effect.succeed(fallback)) + ); + +/** + * Wraps a promise-returning function with proper Effect error handling + */ +export const tryAsync = (fn: () => Promise, onError: (cause: unknown) => E): Effect.Effect => + Effect.tryPromise({ + try: fn, + catch: onError, + }); diff --git a/apps/live/src/services/pdf-export/index.ts b/apps/live/src/services/pdf-export/index.ts new file mode 100644 index 000000000..fa2a7c68d --- /dev/null +++ b/apps/live/src/services/pdf-export/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export { PdfExportService, exportToPdf } from "./pdf-export.service"; +export * from "./effect-utils"; +export * from "./types"; diff --git a/apps/live/src/services/pdf-export/pdf-export.service.ts b/apps/live/src/services/pdf-export/pdf-export.service.ts new file mode 100644 index 000000000..e9c67fc36 --- /dev/null +++ b/apps/live/src/services/pdf-export/pdf-export.service.ts @@ -0,0 +1,379 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Effect } from "effect"; +import sharp from "sharp"; +import { getAllDocumentFormatsFromDocumentEditorBinaryData } from "@plane/editor/lib"; +import type { PDFExportMetadata, TipTapDocument } from "@/lib/pdf"; +import { renderPlaneDocToPdfBuffer } from "@/lib/pdf"; +import { getPageService } from "@/services/page/handler"; +import type { TDocumentTypes } from "@/types"; +import { + PdfContentFetchError, + PdfGenerationError, + PdfImageProcessingError, + PdfTimeoutError, +} from "@/schema/pdf-export"; +import { withTimeoutAndRetry, recoverWithDefault, tryAsync } from "./effect-utils"; +import type { PdfExportInput, PdfExportResult, PageContent, MetadataResult } from "./types"; + +const IMAGE_CONCURRENCY = 4; +const IMAGE_TIMEOUT_MS = 8000; +const CONTENT_FETCH_TIMEOUT_MS = 7000; +const PDF_RENDER_TIMEOUT_MS = 15000; +const IMAGE_MAX_DIMENSION = 1200; + +type TipTapNode = { + type: string; + attrs?: Record; + content?: TipTapNode[]; +}; + +/** + * PDF Export Service + */ +export class PdfExportService extends Effect.Service()("PdfExportService", { + sync: () => ({ + /** + * Determines document type + */ + getDocumentType: (_input: PdfExportInput): TDocumentTypes => { + return "project_page"; + }, + + /** + * Extracts image asset IDs from document content + */ + extractImageAssetIds: (doc: TipTapNode): string[] => { + const assetIds: string[] = []; + + const traverse = (node: TipTapNode) => { + if ((node.type === "imageComponent" || node.type === "image") && node.attrs?.src) { + const src = node.attrs.src as string; + if (src && !src.startsWith("http") && !src.startsWith("data:")) { + assetIds.push(src); + } + } + if (node.content) { + for (const child of node.content) { + traverse(child); + } + } + }; + + traverse(doc); + return [...new Set(assetIds)]; + }, + + /** + * Fetches page content (description binary) and parses it + */ + fetchPageContent: ( + pageService: ReturnType, + pageId: string, + requestId: string + ): Effect.Effect => + Effect.gen(function* () { + yield* Effect.logDebug("PDF_EXPORT: Fetching page content", { requestId, pageId }); + + const descriptionBinary = yield* tryAsync( + () => pageService.fetchDescriptionBinary(pageId), + (cause) => + new PdfContentFetchError({ + message: "Failed to fetch page content", + cause, + }) + ).pipe( + withTimeoutAndRetry("fetch page content", { + timeoutMs: CONTENT_FETCH_TIMEOUT_MS, + maxRetries: 3, + }) + ); + + if (!descriptionBinary) { + return yield* Effect.fail( + new PdfContentFetchError({ + message: "Page content not found", + }) + ); + } + + const binaryData = new Uint8Array(descriptionBinary); + const { contentJSON, titleHTML } = getAllDocumentFormatsFromDocumentEditorBinaryData(binaryData, true); + + return { + contentJSON: contentJSON as TipTapDocument, + titleHTML: titleHTML || null, + descriptionBinary, + }; + }), + + /** + * Fetches user mentions for the page + */ + fetchUserMentions: ( + pageService: ReturnType, + pageId: string, + requestId: string + ): Effect.Effect => + Effect.gen(function* () { + yield* Effect.logDebug("PDF_EXPORT: Fetching user mentions", { requestId }); + + const userMentionsRaw = yield* tryAsync( + async () => { + if (pageService.fetchUserMentions) { + return await pageService.fetchUserMentions(pageId); + } + return []; + }, + () => [] + ).pipe(recoverWithDefault([] as Array<{ id: string; display_name: string; avatar_url?: string }>)); + + return { + userMentions: userMentionsRaw.map((u) => ({ + id: u.id, + display_name: u.display_name, + avatar_url: u.avatar_url, + })), + }; + }), + + /** + * Resolves and processes images for PDF embedding + */ + processImages: ( + pageService: ReturnType, + workspaceSlug: string, + projectId: string | undefined, + assetIds: string[], + requestId: string + ): Effect.Effect> => + Effect.gen(function* () { + if (assetIds.length === 0) { + return {}; + } + + yield* Effect.logDebug("PDF_EXPORT: Processing images", { + requestId, + count: assetIds.length, + }); + + // Resolve URLs first + const resolvedUrlMap = yield* tryAsync( + async () => { + const urlMap = new Map(); + for (const assetId of assetIds) { + const url = await pageService.resolveImageAssetUrl?.(workspaceSlug, assetId, projectId); + if (url) urlMap.set(assetId, url); + } + return urlMap; + }, + () => new Map() + ).pipe(recoverWithDefault(new Map())); + + if (resolvedUrlMap.size === 0) { + return {}; + } + + // Process each image + const processSingleImage = ([assetId, url]: [string, string]) => + Effect.gen(function* () { + const response = yield* tryAsync( + () => fetch(url), + (cause) => + new PdfImageProcessingError({ + message: "Failed to fetch image", + assetId, + cause, + }) + ); + + if (!response.ok) { + return yield* Effect.fail( + new PdfImageProcessingError({ + message: `Image fetch returned ${response.status}`, + assetId, + }) + ); + } + + const arrayBuffer = yield* tryAsync( + () => response.arrayBuffer(), + (cause) => + new PdfImageProcessingError({ + message: "Failed to read image body", + assetId, + cause, + }) + ); + + const processedBuffer = yield* tryAsync( + () => + sharp(Buffer.from(arrayBuffer)) + .rotate() + .flatten({ background: { r: 255, g: 255, b: 255 } }) + .resize(IMAGE_MAX_DIMENSION, IMAGE_MAX_DIMENSION, { fit: "inside", withoutEnlargement: true }) + .jpeg({ quality: 85 }) + .toBuffer(), + (cause) => + new PdfImageProcessingError({ + message: "Failed to process image", + assetId, + cause, + }) + ); + + const base64 = processedBuffer.toString("base64"); + return [assetId, `data:image/jpeg;base64,${base64}`] as const; + }).pipe( + withTimeoutAndRetry(`process image ${assetId}`, { + timeoutMs: IMAGE_TIMEOUT_MS, + maxRetries: 1, + }), + Effect.tapError((error) => + Effect.logWarning("PDF_EXPORT: Image processing failed", { + requestId, + assetId, + error, + }) + ), + Effect.catchAll(() => Effect.succeed(null as readonly [string, string] | null)) + ); + + const entries = Array.from(resolvedUrlMap.entries()); + const pairs = yield* Effect.forEach(entries, processSingleImage, { + concurrency: IMAGE_CONCURRENCY, + }); + + const filtered = pairs.filter((p): p is readonly [string, string] => p !== null); + return Object.fromEntries(filtered); + }), + + /** + * Renders document to PDF buffer + */ + renderPdf: ( + contentJSON: TipTapDocument, + metadata: PDFExportMetadata, + options: { + title?: string; + author?: string; + subject?: string; + pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID"; + pageOrientation?: "portrait" | "landscape"; + noAssets?: boolean; + }, + requestId: string + ): Effect.Effect => + Effect.gen(function* () { + yield* Effect.logDebug("PDF_EXPORT: Rendering PDF", { requestId }); + + const pdfBuffer = yield* tryAsync( + () => + renderPlaneDocToPdfBuffer(contentJSON, { + title: options.title, + author: options.author, + subject: options.subject, + pageSize: options.pageSize, + pageOrientation: options.pageOrientation, + metadata, + noAssets: options.noAssets, + }), + (cause) => + new PdfGenerationError({ + message: "Failed to render PDF", + cause, + }) + ).pipe(withTimeoutAndRetry("render PDF", { timeoutMs: PDF_RENDER_TIMEOUT_MS, maxRetries: 0 })); + + yield* Effect.logInfo("PDF_EXPORT: PDF rendered successfully", { + requestId, + size: pdfBuffer.length, + }); + + return pdfBuffer; + }), + }), +}) {} + +/** + * Main export pipeline - orchestrates the entire PDF export process + * Separate function to avoid circular dependency in service definition + */ +export const exportToPdf = ( + input: PdfExportInput +): Effect.Effect => + Effect.gen(function* () { + const service = yield* PdfExportService; + const { requestId, pageId, workspaceSlug, projectId, noAssets } = input; + + yield* Effect.logInfo("PDF_EXPORT: Starting export", { requestId, pageId, workspaceSlug }); + + // Create page service + const documentType = service.getDocumentType(input); + const pageService = getPageService(documentType, { + workspaceSlug, + projectId: projectId || null, + cookie: input.cookie, + documentType, + userId: "", + }); + + // Fetch content + const content = yield* service.fetchPageContent(pageService, pageId, requestId); + + // Extract image asset IDs + const imageAssetIds = service.extractImageAssetIds(content.contentJSON as TipTapNode); + + // Fetch user mentions + let metadata = yield* service.fetchUserMentions(pageService, pageId, requestId); + + // Process images if needed + if (!noAssets && imageAssetIds.length > 0) { + const resolvedImages = yield* service.processImages( + pageService, + workspaceSlug, + projectId, + imageAssetIds, + requestId + ); + metadata = { ...metadata, resolvedImageUrls: resolvedImages }; + } + + yield* Effect.logDebug("PDF_EXPORT: Metadata prepared", { + requestId, + userMentions: metadata.userMentions?.length ?? 0, + resolvedImages: Object.keys(metadata.resolvedImageUrls ?? {}).length, + }); + + // Render PDF + const documentTitle = input.title || content.titleHTML || undefined; + const pdfBuffer = yield* service.renderPdf( + content.contentJSON, + metadata, + { + title: documentTitle, + author: input.author, + subject: input.subject, + pageSize: input.pageSize, + pageOrientation: input.pageOrientation, + noAssets, + }, + requestId + ); + + yield* Effect.logInfo("PDF_EXPORT: Export complete", { + requestId, + pageId, + size: pdfBuffer.length, + }); + + return { + pdfBuffer, + outputFileName: input.fileName || `page-${pageId}.pdf`, + pageId, + }; + }); diff --git a/apps/live/src/services/pdf-export/types.ts b/apps/live/src/services/pdf-export/types.ts new file mode 100644 index 000000000..1a95b0ece --- /dev/null +++ b/apps/live/src/services/pdf-export/types.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { TipTapDocument, PDFUserMention } from "@/lib/pdf"; + +export interface PdfExportInput { + readonly pageId: string; + readonly workspaceSlug: string; + readonly projectId?: string; + readonly title?: string; + readonly author?: string; + readonly subject?: string; + readonly pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID"; + readonly pageOrientation?: "portrait" | "landscape"; + readonly fileName?: string; + readonly noAssets?: boolean; + readonly cookie: string; + readonly requestId: string; +} + +export interface PdfExportResult { + readonly pdfBuffer: Buffer; + readonly outputFileName: string; + readonly pageId: string; +} + +export interface PageContent { + readonly contentJSON: TipTapDocument; + readonly titleHTML: string | null; + readonly descriptionBinary: Buffer; +} + +/** + * Metadata - includes user mentions + */ +export interface MetadataResult { + readonly userMentions: PDFUserMention[]; + readonly resolvedImageUrls?: Record; +} diff --git a/apps/live/src/services/user.service.ts b/apps/live/src/services/user.service.ts index 272d7543c..b4c285921 100644 --- a/apps/live/src/services/user.service.ts +++ b/apps/live/src/services/user.service.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + // types import { logger } from "@plane/logger"; import type { IUser } from "@plane/types"; diff --git a/apps/live/src/start.ts b/apps/live/src/start.ts index ced70a209..705eb12f3 100644 --- a/apps/live/src/start.ts +++ b/apps/live/src/start.ts @@ -1,5 +1,8 @@ -import { setupSentry } from "./instrument"; -setupSentry(); +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ import { logger } from "@plane/logger"; import { AppError } from "@/lib/errors"; diff --git a/apps/live/src/types/admin-commands.ts b/apps/live/src/types/admin-commands.ts index bd8e5cd59..1cbe7a537 100644 --- a/apps/live/src/types/admin-commands.ts +++ b/apps/live/src/types/admin-commands.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + /** * Type-safe admin commands for server-to-server communication */ diff --git a/apps/live/src/types/index.ts b/apps/live/src/types/index.ts index 6c05fb835..39c941d09 100644 --- a/apps/live/src/types/index.ts +++ b/apps/live/src/types/index.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import type { fetchPayload, onLoadDocumentPayload, storePayload } from "@hocuspocus/server"; export type TConvertDocumentRequestBody = { diff --git a/apps/live/src/utils/broadcast-error.ts b/apps/live/src/utils/broadcast-error.ts index d9dbbc485..4d1077c9f 100644 --- a/apps/live/src/utils/broadcast-error.ts +++ b/apps/live/src/utils/broadcast-error.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import type { Hocuspocus } from "@hocuspocus/server"; import { createRealtimeEvent } from "@plane/editor"; import { logger } from "@plane/logger"; diff --git a/apps/live/src/utils/broadcast-message.ts b/apps/live/src/utils/broadcast-message.ts index c60ce9ac7..473a3c731 100644 --- a/apps/live/src/utils/broadcast-message.ts +++ b/apps/live/src/utils/broadcast-message.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import type { Hocuspocus } from "@hocuspocus/server"; import type { BroadcastedEvent } from "@plane/editor"; import { logger } from "@plane/logger"; diff --git a/apps/live/tests/lib/pdf/pdf-rendering.test.ts b/apps/live/tests/lib/pdf/pdf-rendering.test.ts new file mode 100644 index 000000000..507c6f900 --- /dev/null +++ b/apps/live/tests/lib/pdf/pdf-rendering.test.ts @@ -0,0 +1,732 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { describe, it, expect } from "vitest"; +import { PDFParse } from "pdf-parse"; +import { renderPlaneDocToPdfBuffer } from "@/lib/pdf"; +import type { TipTapDocument, PDFExportMetadata } from "@/lib/pdf"; + +const PDF_HEADER = "%PDF-"; + +/** + * Helper to extract text content from a PDF buffer + */ +async function extractPdfText(buffer: Buffer): Promise { + const uint8 = new Uint8Array(buffer); + const parser = new PDFParse(uint8); + const result = await parser.getText(); + return result.pages.map((p) => p.text).join("\n"); +} + +describe("PDF Rendering Integration", () => { + describe("renderPlaneDocToPdfBuffer", () => { + it("should render empty document to valid PDF", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.length).toBeGreaterThan(0); + expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER); + }); + + it("should render document with title and verify content", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Hello World" }], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc, { + title: "Test Document", + }); + + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER); + + const text = await extractPdfText(buffer); + expect(text).toContain("Hello World"); + // Title is rendered in PDF content when provided + expect(text).toContain("Test Document"); + }); + + it("should render heading nodes and verify text", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: "Main Heading" }], + }, + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Subheading" }], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Main Heading"); + expect(text).toContain("Subheading"); + }); + + it("should render paragraph with text and verify content", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "This is a test paragraph with some content." }], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("This is a test paragraph with some content."); + }); + + it("should render bullet list with all items", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "First item" }], + }, + ], + }, + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Second item" }], + }, + ], + }, + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Third item" }], + }, + ], + }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("First item"); + expect(text).toContain("Second item"); + expect(text).toContain("Third item"); + // Bullet points should be present + expect(text).toContain("•"); + }); + + it("should render ordered list with numbers", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "orderedList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Step one" }], + }, + ], + }, + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Step two" }], + }, + ], + }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Step one"); + expect(text).toContain("Step two"); + // Numbers should be present + expect(text).toMatch(/1\./); + expect(text).toMatch(/2\./); + }); + + it("should render task list with task text", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: true }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Completed task" }], + }, + ], + }, + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Pending task" }], + }, + ], + }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Completed task"); + expect(text).toContain("Pending task"); + }); + + it("should render code block with code content", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "codeBlock", + content: [ + { type: "text", text: "const greeting = 'Hello';\n" }, + { type: "text", text: "console.log(greeting);" }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("const greeting"); + expect(text).toContain("console.log"); + }); + + it("should render blockquote with quoted text", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "blockquote", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "This is a quoted text." }], + }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("This is a quoted text."); + }); + + it("should render table with all cell content", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tableRow", + content: [ + { + type: "tableHeader", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 1" }], + }, + ], + }, + { + type: "tableHeader", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 2" }], + }, + ], + }, + ], + }, + { + type: "tableRow", + content: [ + { + type: "tableCell", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 1" }], + }, + ], + }, + { + type: "tableCell", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 2" }], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Header 1"); + expect(text).toContain("Header 2"); + expect(text).toContain("Cell 1"); + expect(text).toContain("Cell 2"); + }); + + it("should render horizontal rule with surrounding text", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Before rule" }], + }, + { type: "horizontalRule" }, + { + type: "paragraph", + content: [{ type: "text", text: "After rule" }], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Before rule"); + expect(text).toContain("After rule"); + }); + + it("should render text with marks (bold, italic) preserving content", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Normal " }, + { + type: "text", + text: "bold", + marks: [{ type: "bold" }], + }, + { type: "text", text: " and " }, + { + type: "text", + text: "italic", + marks: [{ type: "italic" }], + }, + { type: "text", text: " text." }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Normal"); + expect(text).toContain("bold"); + expect(text).toContain("italic"); + expect(text).toContain("text."); + }); + + it("should render link marks with link text", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Click " }, + { + type: "text", + text: "here", + marks: [{ type: "link", attrs: { href: "https://example.com" } }], + }, + { type: "text", text: " to visit." }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Click"); + expect(text).toContain("here"); + expect(text).toContain("to visit"); + }); + }); + + describe("page options", () => { + it("should support different page sizes and verify content renders", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Page size test content" }], + }, + ], + }; + + const a4Buffer = await renderPlaneDocToPdfBuffer(doc, { pageSize: "A4" }); + const letterBuffer = await renderPlaneDocToPdfBuffer(doc, { pageSize: "LETTER" }); + + const a4Text = await extractPdfText(a4Buffer); + const letterText = await extractPdfText(letterBuffer); + + expect(a4Text).toContain("Page size test content"); + expect(letterText).toContain("Page size test content"); + // Different page sizes should produce different PDF sizes + expect(a4Buffer.length).not.toBe(letterBuffer.length); + }); + + it("should support landscape orientation and verify content", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Landscape content here" }], + }, + ], + }; + + const portraitBuffer = await renderPlaneDocToPdfBuffer(doc, { pageOrientation: "portrait" }); + const landscapeBuffer = await renderPlaneDocToPdfBuffer(doc, { pageOrientation: "landscape" }); + + const portraitText = await extractPdfText(portraitBuffer); + const landscapeText = await extractPdfText(landscapeBuffer); + + expect(portraitText).toContain("Landscape content here"); + expect(landscapeText).toContain("Landscape content here"); + expect(portraitBuffer.length).not.toBe(landscapeBuffer.length); + }); + + it("should include author metadata in PDF", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Document content" }], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc, { + author: "Test Author", + }); + + // Verify PDF is valid and contains content + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER); + // Author metadata is embedded in PDF info dict (checked via raw bytes) + const pdfString = buffer.toString("latin1"); + expect(pdfString).toContain("/Author"); + }); + + it("should include subject metadata in PDF", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Document content" }], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc, { + subject: "Technical Documentation", + }); + + // Verify PDF is valid + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER); + // Subject metadata is embedded in PDF info dict + const pdfString = buffer.toString("latin1"); + expect(pdfString).toContain("/Subject"); + }); + }); + + describe("metadata rendering", () => { + it("should render user mentions with resolved display name", async () => { + const metadata: PDFExportMetadata = { + userMentions: [{ id: "user-123", display_name: "John Doe" }], + }; + + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Hello " }, + { + type: "mention", + attrs: { + entity_name: "user_mention", + entity_identifier: "user-123", + }, + }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc, { metadata }); + const text = await extractPdfText(buffer); + + expect(text).toContain("Hello"); + expect(text).toContain("John Doe"); + }); + }); + + describe("complex documents", () => { + it("should render a full document with mixed content and verify all sections", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: "Project Overview" }], + }, + { + type: "paragraph", + content: [ + { type: "text", text: "This document describes the " }, + { type: "text", text: "key features", marks: [{ type: "bold" }] }, + { type: "text", text: " of the project." }, + ], + }, + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Features" }], + }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Feature A - Core functionality" }], + }, + ], + }, + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Feature B - Advanced options" }], + }, + ], + }, + ], + }, + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Code Example" }], + }, + { + type: "codeBlock", + content: [{ type: "text", text: "function hello() {\n return 'world';\n}" }], + }, + { + type: "blockquote", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Important: Review before deployment." }], + }, + ], + }, + { type: "horizontalRule" }, + { + type: "paragraph", + content: [{ type: "text", text: "End of document." }], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc, { + title: "Project Overview", + author: "Development Team", + subject: "Technical Documentation", + }); + + const text = await extractPdfText(buffer); + + // Verify metadata is embedded in PDF + const pdfString = buffer.toString("latin1"); + expect(pdfString).toContain("/Title"); + expect(pdfString).toContain("/Author"); + expect(pdfString).toContain("/Subject"); + + // Verify all content sections are present + expect(text).toContain("Project Overview"); + expect(text).toContain("This document describes the"); + expect(text).toContain("key features"); + expect(text).toContain("Features"); + expect(text).toContain("Feature A - Core functionality"); + expect(text).toContain("Feature B - Advanced options"); + expect(text).toContain("Code Example"); + expect(text).toContain("function hello"); + expect(text).toContain("Important: Review before deployment"); + expect(text).toContain("End of document"); + }); + + it("should render deeply nested lists with all levels", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Level 1" }], + }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Level 2" }], + }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Level 3" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Level 1"); + expect(text).toContain("Level 2"); + expect(text).toContain("Level 3"); + }); + }); + + describe("noAssets option", () => { + it("should render text but skip images when noAssets is true", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "image", + attrs: { src: "https://example.com/image.png" }, + }, + { + type: "paragraph", + content: [{ type: "text", text: "Text after image" }], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc, { noAssets: true }); + const text = await extractPdfText(buffer); + + expect(text).toContain("Text after image"); + }); + }); +}); diff --git a/apps/live/tests/services/pdf-export/effect-utils.test.ts b/apps/live/tests/services/pdf-export/effect-utils.test.ts new file mode 100644 index 000000000..44ff35e67 --- /dev/null +++ b/apps/live/tests/services/pdf-export/effect-utils.test.ts @@ -0,0 +1,155 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { describe, it, expect, assert } from "vitest"; +import { Effect, Duration, Either } from "effect"; +import { withTimeoutAndRetry, recoverWithDefault, tryAsync } from "@/services/pdf-export/effect-utils"; +import { PdfTimeoutError } from "@/schema/pdf-export"; + +describe("effect-utils", () => { + describe("withTimeoutAndRetry", () => { + it("should succeed when effect completes within timeout", async () => { + const effect = Effect.succeed("success"); + const wrapped = withTimeoutAndRetry("test-operation")(effect); + + const result = await Effect.runPromise(wrapped); + expect(result).toBe("success"); + }); + + it("should fail with PdfTimeoutError when effect exceeds timeout", async () => { + const slowEffect = Effect.gen(function* () { + yield* Effect.sleep(Duration.millis(500)); + return "success"; + }); + + const wrapped = withTimeoutAndRetry("test-operation", { + timeoutMs: 50, + maxRetries: 0, + })(slowEffect); + + const result = await Effect.runPromise(Effect.either(wrapped)); + + assert(Either.isLeft(result), "Expected Left but got Right"); + expect(result.left).toBeInstanceOf(PdfTimeoutError); + expect((result.left as PdfTimeoutError).operation).toBe("test-operation"); + }); + + it("should retry on failure up to maxRetries times", async () => { + const attemptCounter = { count: 0 }; + + const flakyEffect = Effect.gen(function* () { + attemptCounter.count++; + if (attemptCounter.count < 3) { + return yield* Effect.fail(new Error("transient failure")); + } + return "success"; + }); + + const wrapped = withTimeoutAndRetry("test-operation", { + timeoutMs: 5000, + maxRetries: 3, + })(flakyEffect); + + const result = await Effect.runPromise(wrapped); + + expect(result).toBe("success"); + expect(attemptCounter.count).toBe(3); + }); + + it("should fail after exhausting retries", async () => { + const effect = Effect.fail(new Error("permanent failure")); + + const wrapped = withTimeoutAndRetry("test-operation", { + timeoutMs: 5000, + maxRetries: 2, + })(effect); + + const result = await Effect.runPromise(Effect.either(wrapped)); + + expect(result._tag).toBe("Left"); + }); + }); + + describe("recoverWithDefault", () => { + it("should return success value when effect succeeds", async () => { + const effect = Effect.succeed("success"); + const wrapped = recoverWithDefault("fallback")(effect); + + const result = await Effect.runPromise(wrapped); + expect(result).toBe("success"); + }); + + it("should return fallback value when effect fails", async () => { + const effect = Effect.fail(new Error("failure")); + const wrapped = recoverWithDefault("fallback")(effect); + + const result = await Effect.runPromise(wrapped); + expect(result).toBe("fallback"); + }); + + it("should log warning when recovering from error", async () => { + const logs: string[] = []; + + const effect = Effect.fail(new Error("test error")).pipe( + recoverWithDefault("fallback"), + Effect.tap(() => Effect.sync(() => logs.push("after recovery"))) + ); + + const result = await Effect.runPromise(effect); + + expect(result).toBe("fallback"); + expect(logs).toContain("after recovery"); + }); + + it("should work with complex fallback objects", async () => { + const fallback = { items: [], count: 0, metadata: { version: 1 } }; + + const effect = Effect.fail(new Error("failure")); + const wrapped = recoverWithDefault(fallback)(effect); + + const result = await Effect.runPromise(wrapped); + expect(result).toEqual(fallback); + }); + }); + + describe("tryAsync", () => { + it("should wrap successful promise", async () => { + const effect = tryAsync( + () => Promise.resolve("success"), + (err) => new Error(`wrapped: ${err}`) + ); + + const result = await Effect.runPromise(effect); + expect(result).toBe("success"); + }); + + it("should wrap rejected promise with custom error", async () => { + const effect = tryAsync( + () => Promise.reject(new Error("original")), + (err) => new Error(`wrapped: ${(err as Error).message}`) + ); + + const result = await Effect.runPromise(Effect.either(effect)); + + assert(Either.isLeft(result), "Expected Left but got Right"); + expect(result.left.message).toBe("wrapped: original"); + }); + + it("should handle synchronous throws", async () => { + const effect = tryAsync( + () => { + throw new Error("sync error"); + }, + (err) => new Error(`caught: ${(err as Error).message}`) + ); + + const result = await Effect.runPromise(Effect.either(effect)); + + assert(Either.isLeft(result), "Expected Left but got Right"); + expect(result.left.message).toBe("caught: sync error"); + }); + }); +}); diff --git a/apps/live/tsconfig.json b/apps/live/tsconfig.json index a3a901c9c..9d49437c0 100644 --- a/apps/live/tsconfig.json +++ b/apps/live/tsconfig.json @@ -6,6 +6,7 @@ "noImplicitOverride": false, "noImplicitReturns": false, "noUnusedLocals": false, + "jsx": "react-jsx", "paths": { "@/*": ["./src/*"], @@ -14,6 +15,6 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true }, - "include": ["src"], + "include": ["src", "tests"], "exclude": ["node_modules", "dist"] } diff --git a/apps/live/vitest.config.ts b/apps/live/vitest.config.ts new file mode 100644 index 000000000..d9a1624cd --- /dev/null +++ b/apps/live/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + include: ["tests/**/*.test.ts", "tests/**/*.spec.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.d.ts", "src/**/types.ts"], + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/apps/proxy/Caddyfile.ce b/apps/proxy/Caddyfile.ce index 9b9acc7b0..14559f272 100644 --- a/apps/proxy/Caddyfile.ce +++ b/apps/proxy/Caddyfile.ce @@ -15,6 +15,8 @@ reverse_proxy /auth/* api:8000 + reverse_proxy /static/* api:8000 + reverse_proxy /{$BUCKET_NAME}/* plane-minio:9000 reverse_proxy /{$BUCKET_NAME} plane-minio:9000 diff --git a/apps/space/Dockerfile.space b/apps/space/Dockerfile.space index 4c121fa74..60d4a155a 100644 --- a/apps/space/Dockerfile.space +++ b/apps/space/Dockerfile.space @@ -13,7 +13,7 @@ RUN corepack enable pnpm FROM base AS builder -RUN pnpm add -g turbo@2.6.3 +RUN pnpm add -g turbo@2.9.4 COPY . . @@ -86,7 +86,8 @@ WORKDIR /app/apps/space EXPOSE 3000 +RUN apk add --no-cache curl HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD curl -fsS http://127.0.0.1:3000/ >/dev/null || exit 1 + CMD curl -fsS http://127.0.0.1:3000/spaces/ >/dev/null || exit 1 CMD ["npx", "react-router-serve", "./build/server/index.js"] diff --git a/apps/space/app/[workspaceSlug]/[projectId]/page.tsx b/apps/space/app/[workspaceSlug]/[projectId]/page.tsx index 013e7f8c1..b14ec17c0 100644 --- a/apps/space/app/[workspaceSlug]/[projectId]/page.tsx +++ b/apps/space/app/[workspaceSlug]/[projectId]/page.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { redirect } from "react-router"; // plane imports import { SitesProjectPublishService } from "@plane/services"; @@ -44,7 +50,7 @@ export const clientLoader = async ({ params, request }: Route.ClientLoaderArgs) export default function IssuesPage() { return ( -
    +
    ); diff --git a/apps/space/app/compat/next/helper.ts b/apps/space/app/compat/next/helper.ts index c04699870..c4edf3d54 100644 --- a/apps/space/app/compat/next/helper.ts +++ b/apps/space/app/compat/next/helper.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + /** * Ensures that a URL has a trailing slash while preserving query parameters and fragments * @param url - The URL to process diff --git a/apps/space/app/compat/next/image.tsx b/apps/space/app/compat/next/image.tsx deleted file mode 100644 index 062638de4..000000000 --- a/apps/space/app/compat/next/image.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from "react"; - -// Minimal shim so code using next/image compiles under React Router + Vite -// without changing call sites. It just renders a native img. - -type NextImageProps = React.ImgHTMLAttributes & { - src: string; -}; - -function Image({ src, alt = "", ...rest }: NextImageProps) { - return {alt}; -} - -export default Image; diff --git a/apps/space/app/compat/next/link.tsx b/apps/space/app/compat/next/link.tsx deleted file mode 100644 index b0bca4faf..000000000 --- a/apps/space/app/compat/next/link.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from "react"; -import { Link as RRLink } from "react-router"; -import { ensureTrailingSlash } from "./helper"; - -type NextLinkProps = React.ComponentProps<"a"> & { - href: string; - replace?: boolean; - prefetch?: boolean; // next.js prop, ignored - scroll?: boolean; // next.js prop, ignored - shallow?: boolean; // next.js prop, ignored -}; - -function Link({ href, replace, prefetch: _prefetch, scroll: _scroll, shallow: _shallow, ...rest }: NextLinkProps) { - return ; -} - -export default Link; diff --git a/apps/space/app/compat/next/navigation.ts b/apps/space/app/compat/next/navigation.ts index a825b1e65..0aa9254bc 100644 --- a/apps/space/app/compat/next/navigation.ts +++ b/apps/space/app/compat/next/navigation.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useMemo } from "react"; import { useLocation, useNavigate, useParams as useParamsRR, useSearchParams as useSearchParamsRR } from "react-router"; import { ensureTrailingSlash } from "./helper"; diff --git a/apps/space/app/entry.client.tsx b/apps/space/app/entry.client.tsx index 9cf1c32de..9c665ede0 100644 --- a/apps/space/app/entry.client.tsx +++ b/apps/space/app/entry.client.tsx @@ -1,28 +1,13 @@ -import * as Sentry from "@sentry/react-router"; +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; -Sentry.init({ - dsn: process.env.VITE_SENTRY_DSN, - environment: process.env.VITE_SENTRY_ENVIRONMENT, - sendDefaultPii: process.env.VITE_SENTRY_SEND_DEFAULT_PII ? process.env.VITE_SENTRY_SEND_DEFAULT_PII === "1" : false, - release: process.env.VITE_APP_VERSION, - tracesSampleRate: process.env.VITE_SENTRY_TRACES_SAMPLE_RATE - ? parseFloat(process.env.VITE_SENTRY_TRACES_SAMPLE_RATE) - : 0.1, - profilesSampleRate: process.env.VITE_SENTRY_PROFILES_SAMPLE_RATE - ? parseFloat(process.env.VITE_SENTRY_PROFILES_SAMPLE_RATE) - : 0.1, - replaysSessionSampleRate: process.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE - ? parseFloat(process.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE) - : 0.1, - replaysOnErrorSampleRate: process.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE - ? parseFloat(process.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE) - : 1.0, - integrations: [], -}); - startTransition(() => { hydrateRoot( document, diff --git a/apps/space/app/error.tsx b/apps/space/app/error.tsx index 2330b9033..87aa8dc19 100644 --- a/apps/space/app/error.tsx +++ b/apps/space/app/error.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + // ui import { Button } from "@plane/propel/button"; @@ -7,33 +13,28 @@ function ErrorPage() { }; return ( -
    +
    - - {/* */}
    diff --git a/apps/space/app/issues/[anchor]/layout.tsx b/apps/space/app/issues/[anchor]/layout.tsx index 60a171bcf..6dbc2e711 100644 --- a/apps/space/app/issues/[anchor]/layout.tsx +++ b/apps/space/app/issues/[anchor]/layout.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; import { Outlet } from "react-router"; import type { ShouldRevalidateFunctionArgs } from "react-router"; @@ -114,7 +120,7 @@ function IssuesLayout(props: Route.ComponentProps) { if (!publishSettings && !error) { return ( -
    +
    ); @@ -127,10 +133,10 @@ function IssuesLayout(props: Route.ComponentProps) { return ( <>
    -
    +
    -
    +
    diff --git a/apps/space/app/issues/[anchor]/page.tsx b/apps/space/app/issues/[anchor]/page.tsx index 32cdcad86..cd19bda4b 100644 --- a/apps/space/app/issues/[anchor]/page.tsx +++ b/apps/space/app/issues/[anchor]/page.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { observer } from "mobx-react"; import { useParams, useSearchParams } from "next/navigation"; import useSWR from "swr"; diff --git a/apps/space/app/not-found.tsx b/apps/space/app/not-found.tsx index 9fcfd8a74..e137fdd87 100644 --- a/apps/space/app/not-found.tsx +++ b/apps/space/app/not-found.tsx @@ -1,17 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + // assets import SomethingWentWrongImage from "@/app/assets/something-went-wrong.svg?url"; function NotFound() { return ( -
    +
    -
    -
    +
    +
    Something went wrong
    -

    That didn{"'"}t work

    -

    +

    That didn{"'"}t work

    +

    Check the URL you are entering in the browser{"'"}s address bar and try again.

    diff --git a/apps/space/app/page.tsx b/apps/space/app/page.tsx index 4b473b566..02a9a5649 100644 --- a/apps/space/app/page.tsx +++ b/apps/space/app/page.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useEffect } from "react"; import { observer } from "mobx-react"; import { useSearchParams, useRouter } from "next/navigation"; @@ -29,7 +35,7 @@ const HomePage = observer(function HomePage() { if (isInitializing) return ( -
    +
    ); @@ -37,7 +43,7 @@ const HomePage = observer(function HomePage() { if (currentUser && isAuthenticated) { if (nextPath && isValidNextPath(nextPath)) { return ( -
    +
    ); diff --git a/apps/space/app/providers.tsx b/apps/space/app/providers.tsx index 981270cc3..463770ec5 100644 --- a/apps/space/app/providers.tsx +++ b/apps/space/app/providers.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { ThemeProvider } from "next-themes"; // components import { TranslationProvider } from "@plane/i18n"; diff --git a/apps/space/app/root.tsx b/apps/space/app/root.tsx index a7f04af7d..fe504b1ed 100644 --- a/apps/space/app/root.tsx +++ b/apps/space/app/root.tsx @@ -1,4 +1,9 @@ -import * as Sentry from "@sentry/react-router"; +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { Links, Meta, Outlet, Scripts } from "react-router"; // assets import appleTouchIcon from "@/app/assets/favicon/apple-touch-icon.png?url"; @@ -13,6 +18,11 @@ import type { Route } from "./+types/root"; // local imports import ErrorPage from "./error"; import { AppProviders } from "./providers"; +// fonts +import "@fontsource-variable/inter"; +import interVariableWoff2 from "@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?url"; +import "@fontsource/material-symbols-rounded"; +import "@fontsource/ibm-plex-mono"; const APP_TITLE = "Plane Publish | Make your Plane boards public with one-click"; const APP_DESCRIPTION = "Plane Publish is a customer feedback management tool built on top of plane.so"; @@ -24,6 +34,13 @@ export const links: Route.LinksFunction = () => [ { rel: "shortcut icon", href: faviconIco }, { rel: "manifest", href: siteWebmanifest }, { rel: "stylesheet", href: globalStyles }, + { + rel: "preload", + href: interVariableWoff2, + as: "font", + type: "font/woff2", + crossOrigin: "anonymous", + }, ]; export const headers: Route.HeadersFunction = () => ({ @@ -72,16 +89,12 @@ export default function Root() { export function HydrateFallback() { return ( -
    +
    ); } -export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { - if (error) { - Sentry.captureException(error); - } - +export function ErrorBoundary({ error: _error }: Route.ErrorBoundaryProps) { return ; } diff --git a/apps/space/app/routes.ts b/apps/space/app/routes.ts index 36c3d20fa..1a94ca0f7 100644 --- a/apps/space/app/routes.ts +++ b/apps/space/app/routes.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import type { RouteConfig } from "@react-router/dev/routes"; import { index, layout, route } from "@react-router/dev/routes"; diff --git a/apps/space/ce/components/editor/embeds/mentions/index.ts b/apps/space/ce/components/editor/embeds/mentions/index.ts deleted file mode 100644 index 1efe34c51..000000000 --- a/apps/space/ce/components/editor/embeds/mentions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./root"; diff --git a/apps/space/ce/components/editor/embeds/mentions/root.tsx b/apps/space/ce/components/editor/embeds/mentions/root.tsx deleted file mode 100644 index 6fc36cc74..000000000 --- a/apps/space/ce/components/editor/embeds/mentions/root.tsx +++ /dev/null @@ -1,8 +0,0 @@ -// plane editor -import type { TCallbackMentionComponentProps } from "@plane/editor"; - -export type TEditorMentionComponentProps = TCallbackMentionComponentProps; - -export function EditorAdditionalMentionsRoot(_props: TEditorMentionComponentProps) { - return null; -} diff --git a/apps/space/ce/components/issue-layouts/root.tsx b/apps/space/ce/components/issue-layouts/root.tsx deleted file mode 100644 index 95d58029d..000000000 --- a/apps/space/ce/components/issue-layouts/root.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { PageNotFound } from "@/components/ui/not-found"; -import type { PublishStore } from "@/store/publish/publish.store"; - -type Props = { - peekId: string | undefined; - publishSettings: PublishStore; -}; - -export function ViewLayoutsRoot(_props: Props) { - return ; -} diff --git a/apps/space/ce/components/navbar/index.tsx b/apps/space/ce/components/navbar/index.tsx deleted file mode 100644 index e91b2d47e..000000000 --- a/apps/space/ce/components/navbar/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { PublishStore } from "@/store/publish/publish.store"; - -type Props = { - publishSettings: PublishStore; -}; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export function ViewNavbarRoot(props: Props) { - return <>; -} diff --git a/apps/space/ce/hooks/store/index.ts b/apps/space/ce/hooks/store/index.ts deleted file mode 100644 index a5fc99eef..000000000 --- a/apps/space/ce/hooks/store/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./use-published-view"; diff --git a/apps/space/ce/hooks/store/use-published-view.ts b/apps/space/ce/hooks/store/use-published-view.ts deleted file mode 100644 index 170d934da..000000000 --- a/apps/space/ce/hooks/store/use-published-view.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const useView = () => ({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - fetchViewDetails: (anchor: string) => {}, - viewData: {}, -}); diff --git a/apps/space/ce/store/root.store.ts b/apps/space/ce/store/root.store.ts deleted file mode 100644 index 710462e13..000000000 --- a/apps/space/ce/store/root.store.ts +++ /dev/null @@ -1,8 +0,0 @@ -// store -import { CoreRootStore } from "@/store/root.store"; - -export class RootStore extends CoreRootStore { - constructor() { - super(); - } -} diff --git a/apps/space/components/account/auth-forms/auth-banner.tsx b/apps/space/components/account/auth-forms/auth-banner.tsx new file mode 100644 index 000000000..4a9c517b7 --- /dev/null +++ b/apps/space/components/account/auth-forms/auth-banner.tsx @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Info } from "lucide-react"; +import { CloseIcon } from "@plane/propel/icons"; +// helpers +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; + +type TAuthBanner = { + bannerData: TAuthErrorInfo | undefined; + handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void; +}; + +export function AuthBanner(props: TAuthBanner) { + const { bannerData, handleBannerData } = props; + + if (!bannerData) return <>; + return ( +
    +
    + +
    +
    {bannerData?.message}
    +
    handleBannerData && handleBannerData(undefined)} + > + +
    +
    + ); +} diff --git a/apps/space/core/components/account/auth-forms/auth-header.tsx b/apps/space/components/account/auth-forms/auth-header.tsx similarity index 76% rename from apps/space/core/components/account/auth-forms/auth-header.tsx rename to apps/space/components/account/auth-forms/auth-header.tsx index 9b0c95c3e..a2063fe73 100644 --- a/apps/space/core/components/account/auth-forms/auth-header.tsx +++ b/apps/space/components/account/auth-forms/auth-header.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + // helpers import { EAuthModes } from "@/types/auth"; @@ -44,8 +50,8 @@ export function AuthHeader(props: TAuthHeader) { return ( <>
    - {header} - {subHeader} + {header} + {subHeader}
    ); diff --git a/apps/space/core/components/account/auth-forms/auth-root.tsx b/apps/space/components/account/auth-forms/auth-root.tsx similarity index 71% rename from apps/space/core/components/account/auth-forms/auth-root.tsx rename to apps/space/components/account/auth-forms/auth-root.tsx index 0308471ec..d31649f78 100644 --- a/apps/space/core/components/account/auth-forms/auth-root.tsx +++ b/apps/space/components/account/auth-forms/auth-root.tsx @@ -1,22 +1,21 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; -import { useTheme } from "next-themes"; // plane imports -import { API_BASE_URL } from "@plane/constants"; import { SitesAuthService } from "@plane/services"; import type { IEmailCheckData } from "@plane/types"; import { OAuthOptions } from "@plane/ui"; -// assets -import GiteaLogo from "@/app/assets/logos/gitea-logo.svg?url"; -import GithubLightLogo from "@/app/assets/logos/github-black.png?url"; -import GithubDarkLogo from "@/app/assets/logos/github-dark.svg?url"; -import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; -import GoogleLogo from "@/app/assets/logos/google-logo.svg?url"; // helpers import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; import { EErrorAlertType, authErrorHandler, EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; // hooks +import { useOAuthConfig } from "@/hooks/oauth"; import { useInstance } from "@/hooks/store/use-instance"; // types import { EAuthModes, EAuthSteps } from "@/types/auth"; @@ -36,7 +35,6 @@ export const AuthRoot = observer(function AuthRoot() { const emailParam = searchParams.get("email") || undefined; const error_code = searchParams.get("error_code") || undefined; const nextPath = searchParams.get("next_path") || undefined; - const next_path = searchParams.get("next_path"); // states const [authMode, setAuthMode] = useState(EAuthModes.SIGN_UP); const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); @@ -44,7 +42,6 @@ export const AuthRoot = observer(function AuthRoot() { const [errorInfo, setErrorInfo] = useState(undefined); const [isPasswordAutoset, setIsPasswordAutoset] = useState(true); // hooks - const { resolvedTheme } = useTheme(); const { config } = useInstance(); useEffect(() => { @@ -87,13 +84,8 @@ export const AuthRoot = observer(function AuthRoot() { const isSMTPConfigured = config?.is_smtp_configured || false; const isMagicLoginEnabled = config?.is_magic_login_enabled || false; const isEmailPasswordEnabled = config?.is_email_password_enabled || false; - const isOAuthEnabled = - (config && - (config?.is_google_enabled || - config?.is_github_enabled || - config?.is_gitlab_enabled || - config?.is_gitea_enabled)) || - false; + const oAuthActionText = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in"; + const { isOAuthEnabled, oAuthOptions } = useOAuthConfig(oAuthActionText); // submit handler- email verification const handleEmailVerification = async (data: IEmailCheckData) => { @@ -112,7 +104,7 @@ export const AuthRoot = observer(function AuthRoot() { } if (currentAuthMode === EAuthModes.SIGN_IN) { - if (response.is_password_autoset && isSMTPConfigured && isMagicLoginEnabled) { + if (isSMTPConfigured && isMagicLoginEnabled && response.status === "MAGIC_CODE") { setAuthStep(EAuthSteps.UNIQUE_CODE); generateEmailUniqueCode(data.email); } else if (isEmailPasswordEnabled) { @@ -123,7 +115,7 @@ export const AuthRoot = observer(function AuthRoot() { setErrorInfo(errorhandler); } } else { - if (isSMTPConfigured && isMagicLoginEnabled) { + if (isSMTPConfigured && isMagicLoginEnabled && response.status === "MAGIC_CODE") { setAuthStep(EAuthSteps.UNIQUE_CODE); generateEmailUniqueCode(data.email); } else if (isEmailPasswordEnabled) { @@ -133,6 +125,7 @@ export const AuthRoot = observer(function AuthRoot() { setErrorInfo(errorhandler); } } + return; }) .catch((error) => { const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined); @@ -153,62 +146,14 @@ export const AuthRoot = observer(function AuthRoot() { }); }; - const content = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in"; - - const OAuthConfig = [ - { - id: "google", - text: `${content} with Google`, - icon: Google Logo, - onClick: () => { - window.location.assign(`${API_BASE_URL}/auth/google/${next_path ? `?next_path=${next_path}` : ``}`); - }, - enabled: config?.is_google_enabled, - }, - { - id: "github", - text: `${content} with GitHub`, - icon: ( - GitHub Logo - ), - onClick: () => { - window.location.assign(`${API_BASE_URL}/auth/github/${next_path ? `?next_path=${next_path}` : ``}`); - }, - enabled: config?.is_github_enabled, - }, - { - id: "gitlab", - text: `${content} with GitLab`, - icon: GitLab Logo, - onClick: () => { - window.location.assign(`${API_BASE_URL}/auth/gitlab/${next_path ? `?next_path=${next_path}` : ``}`); - }, - enabled: config?.is_gitlab_enabled, - }, - { - id: "gitea", - text: `${content} with Gitea`, - icon: Gitea Logo, - onClick: () => { - window.location.assign(`${API_BASE_URL}/auth/gitea/${next_path ? `?next_path=${next_path}` : ``}`); - }, - enabled: config?.is_gitea_enabled, - }, - ]; - return ( -
    -
    +
    +
    {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( setErrorInfo(value)} /> )} - {isOAuthEnabled && } + {isOAuthEnabled && } {authStep === EAuthSteps.EMAIL && } {authStep === EAuthSteps.UNIQUE_CODE && ( diff --git a/apps/space/core/components/account/auth-forms/email.tsx b/apps/space/components/account/auth-forms/email.tsx similarity index 76% rename from apps/space/core/components/account/auth-forms/email.tsx rename to apps/space/components/account/auth-forms/email.tsx index 9ed44db83..303f9038e 100644 --- a/apps/space/core/components/account/auth-forms/email.tsx +++ b/apps/space/components/account/auth-forms/email.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import type { FormEvent } from "react"; import { useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; @@ -46,13 +52,13 @@ export const AuthEmailForm = observer(function AuthEmailForm(props: TAuthEmailFo return (
    -
    -
    diff --git a/apps/space/components/account/auth-forms/index.ts b/apps/space/components/account/auth-forms/index.ts new file mode 100644 index 000000000..125f6699c --- /dev/null +++ b/apps/space/components/account/auth-forms/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./auth-root"; diff --git a/apps/space/core/components/account/auth-forms/password.tsx b/apps/space/components/account/auth-forms/password.tsx similarity index 82% rename from apps/space/core/components/account/auth-forms/password.tsx rename to apps/space/components/account/auth-forms/password.tsx index 5b99cda2e..7bf8971fa 100644 --- a/apps/space/core/components/account/auth-forms/password.tsx +++ b/apps/space/components/account/auth-forms/password.tsx @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + import React, { useEffect, useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; import { Eye, EyeOff, XCircle } from "lucide-react"; @@ -116,12 +122,10 @@ export const AuthPasswordForm = observer(function AuthPasswordForm(props: Props)
    -