diff --git a/.codespellrc b/.codespellrc deleted file mode 100644 index ffe730b747b..00000000000 --- 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 ec03769295d..277a3bdfa89 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 390c95aaac6..c2bd609c047 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 29c26783168..301080b7590 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 087a012d40c..8ad71e7d68a 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 855ee359fe2..e32581f2e2d 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 e3aba5cf148..a645c192ff3 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 ca87dc9347f..00000000000 --- 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 00000000000..b406833a827 --- /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 dad3489dfec..c0740c517b4 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 11612207b1a..28a623f8e2b 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 7ddaceb7965..719f101187d 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: Check formatting + run: pnpm turbo run check:format --affected + + # Build packages - required for lint and type checks + build: + name: Build packages + runs-on: ubuntu-latest + timeout-minutes: 15 + if: | + github.event.pull_request.draft == false && + github.event.pull_request.requested_reviewers != null + env: + TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }} + TURBO_SCM_HEAD: ${{ github.sha }} + NODE_OPTIONS: "--max-old-space-size=4096" + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 50 + filter: blob:none + + - name: 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 Affected + - name: Build packages run: pnpm turbo run build --affected - - name: Lint 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 - - name: Check Affected format - run: pnpm turbo run check:format --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: Check Affected types + - 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 548ccbf423f..00000000000 --- 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 5d6c72cb758..00000000000 --- 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 f0093a0e68a..e2e6441ba3c 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 ef155d4a139..cf1c0efa798 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 00000000000..83ab91d8e38 --- /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 00000000000..3de0b803795 --- /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 49c61547db6..d0f3d75d266 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 00000000000..2a6fd91ff30 --- /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 00000000000..a72b11a2404 --- /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 f6b364befef..d2117149da6 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 458d00a2e38..19ad2c392a1 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 568289033f1..affbda4808b 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 ebdf40528b1..dec32009827 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 15b97b5880e..f05464a41a9 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 1838cd5b82c..fe8eae4c659 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 a93ffdb9e59..2425216b3a0 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 4fe3c461124..a7a29cf9d8b 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 65f2b776c38..7df6faf17af 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 ba421e04c48..5bcaef7268a 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 f8cda5c0bc9..698ff34e9cb 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 c99c5978772..93a61497d21 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 c4bdb727b15..26e5fc56f9d 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); - - 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", - }, - }); - - await updateConfigPromise - .then(() => { - setIsSubmitting(false); - }) - .catch((err) => { - console.error(err); - setIsSubmitting(false); + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + // 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); + + // Only validate if this is an authentication method key + if (isAuthMethodKey) { + const canDisable = canDisableAuthMethod(key, currentAuthModes, formattedConfig); + + 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; + } + } + } + + // 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 014be381099..794d39a3a05 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 db663f77725..0b402b76c7a 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"; +// hooks import { useInstance } from "@/hooks/store"; +// components import { IntercomConfig } from "./intercom"; -// hooks 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 a7659f42546..656e2d69022 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 5a70e30aa6e..cb0a8c662e6 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 9227a5ba9bf..72ab513398e 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 57dd3a0fd67..e410e87eb86 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 b0a766d2fc5..e56acf7f9cb 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 f2458f869b3..d1ddb1f5a31 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 da09ef348e4..51401f312ca 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 4fe17e0bf2b..319cf0173dd 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 7950879c176..6d9c970c2a5 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 e4c7075aaf4..d250b763059 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 fe3fc033a2f..a31d03d13e3 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 f5d8a678c2b..5816ac9599d 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. -
+ +
+ {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} - /> -
+
+
+
+ { + 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) && ( - - )} -
-
- You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a - Member. -
+
+ ) : ( + + + + )} + {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 3b8fd5ce5cd..43df781bbb0 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 ca2196eda25..ae94821af9a 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 c079759a4b8..ea18dc99599 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 658088ff832..a334536840c 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 12f701c462d..7947adcdcce 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 3d485ebce4d..4e0afb8ea13 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 68bc72d01fa..00000000000 --- 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 4cbb4f81d3e..00000000000 --- 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 c0469987026..c4edf3d5455 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 062638de41f..12a2bb21eda 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 b0bca4faf59..85177560fcb 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 e0e6e90259a..dc59a9a85c7 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 3851daa3272..473dbb6e175 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 9cf1c32deba..9c665ede072 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 89415106d96..5d4eafb765a 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 0f7232439fb..184bed205a7 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 d2aa7485574..00000000000 --- 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 c6a1da8b627..00000000000 --- 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 51b6eb6c4d8..00000000000 --- 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 1be816f70a6..00000000000 --- 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 c512e24d14f..86934bbe752 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 2a53eac1269..0f304335b2f 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 22019979e39..ef9d6db15ac 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 b2db3e08625..06443bf0044 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 7e6ee1ddbaa..dcd3bed2e61 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 d31b38dda9a..556dd5aa601 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 bdc9c4920c6..1b603980d00 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 00000000000..1c56afa3c70 --- /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 aa647e22055..46d4fd1da0f 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 ab46459495e..02c44be22e0 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 4d3534859d2..3d95b4484e0 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 00000000000..58681949d2e --- /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 484cf745401..d161740df88 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 f69b0417937..0f33f13cc7d 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 00000000000..4cfc8ee7187 --- /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 00000000000..b4c3e66d374 --- /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 c2ccc6358e8..48b9ca78e9c 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 4d06d28224e..b74c38d707d 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 bd4c3ee466f..30dda8d2dc1 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 e31633dc9fe..1f1610fbe14 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 00000000000..652b7d30471 --- /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 00000000000..4fa5e06705a --- /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 2b45deff281..293b44bdcc8 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 ab25b1a3e46..74e80db45b6 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 01855eec84c..9594d7f2196 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 df0818b34fa..00000000000 --- 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 ead66b96314..00000000000 --- 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 0473effcdae..00000000000 --- 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 ed1781299f9..00000000000 --- 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 7b531da2b25..00000000000 --- 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 cb0ff5c3b54..00000000000 --- 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 4e3b05a5228..00000000000 --- 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 d2aa7485574..00000000000 --- 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 60441ee25be..00000000000 --- 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 c514c4c25f7..00000000000 --- 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 00000000000..22b5e3b4b63 --- /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 7b0f658d8a0..9e6914e41cc 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 00000000000..74c11e33fcd --- /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 00000000000..3e89ad9367a --- /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 00000000000..3b8df72f679 --- /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 5917df3fa08..4ae991139df 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 d5a1e820e32..2348c2e43ae 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 56b988eb80e..5eba488d90f 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 c4578c91702..42a2bf7f7c5 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 00000000000..9d4e449b3af --- /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 00000000000..cfc76f47eaf --- /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 00000000000..dfe531bb28a --- /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 7ad93fc11bf..e4362581dc1 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 00000000000..592017255e2 --- /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 243aebff54d..0fd4a192ae2 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 573a3ddfe31..8a492643a5e 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,9 +1,9 @@ { "name": "admin", + "version": "1.3.0", + "private": true, "description": "Admin UI for Plane", - "version": "1.2.3", "license": "AGPL-3.0", - "private": true, "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 8a677108f55..00000000000 --- 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 00000000000..3ad28f15f35 --- /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 0406cec0929..3b22c70878a 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 00000000000..72023a79b54 --- /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 00000000000..2ecd419b239 --- /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 5dcd3b6f5f6..50e66962170 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 49e341b7231..9ddb53b6b91 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 1e7e3a11e0c..541678efa5a 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 04242abc9e9..3a840c18650 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 ec892292055..ed92beae118 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 68d11885b75..42f0a63e5bd 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 4512facd2bf..bb663804fe5 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 1187355a04d..c218eec0fa8 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 f9203ed40cb..7dbfaa1af98 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 1b88a170e61..7f7f2483a57 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 9bc917eb428..00000000000 --- 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 90dedb762e1..2d04cbe81f9 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 00000000000..382ca5fe351 --- /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 c9d97157f41..f61d9b49eb5 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 5a1da1570a1..7a64028645e 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 9728694628b..a79268b37c8 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 9f4b7879c11..99f5de98794 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 53f4ccb1d8e..f561e3e630d 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 e69de29bb2d..917e26db4cb 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 52a59f31383..b11b7bfc948 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 e69de29bb2d..917e26db4cb 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 f1f53111882..90688c13062 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 e69de29bb2d..917e26db4cb 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 ddabb4132da..abd81398573 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 0d266e98b50..33df895cbfb 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 6525ddce633..2ab639d5466 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 @@ IssueCommentCreateSerializer, IssueLinkCreateSerializer, IssueLinkUpdateSerializer, + IssueRelationCreateSerializer, + IssueRelationResponseSerializer, + IssueRelationSerializer, + RelatedIssueSerializer, ) from .state import StateLiteSerializer, StateSerializer from .cycle import ( @@ -45,7 +53,7 @@ IntakeIssueCreateSerializer, IntakeIssueUpdateSerializer, ) -from .estimate import EstimatePointSerializer +from .estimate import EstimateSerializer, EstimatePointSerializer from .asset import ( UserAssetUploadSerializer, AssetUpdateSerializer, @@ -55,4 +63,4 @@ ) 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 6b74b375723..363b5eb8428 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 bc790f2cd7b..2e39a01fd5b 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 f2724231a25..9b6aed516d3 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 b670006d53d..978003240b0 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 40cbba38b62..12bbd4572a0 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 5b52dc03c4c..18c1c020665 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 d86dfa6b6ea..6468ddbc84f 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 @@ 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 Meta: ] +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 3aa9644b4c4..266a4cfe117 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 d1e3b0d81a7..8ab73a33eee 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 770957e08c8..644b5ba1076 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 .base import BaseSerializer -class ProjectCreateSerializer(BaseSerializer): +class ProjectCreateSerializer(BaseSerializer): """ Serializer for creating projects with workspace validation. @@ -103,6 +105,15 @@ class Meta: ] 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 @@ def validate(self, data): 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 Meta(ProjectCreateSerializer.Meta): 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 Meta: ] 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 c279529b82b..c07fe78bb9b 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 067fc1b8995..e2ffee8a97e 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 805eb9fe1e9..24bb6b902ff 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 e98683c2fd2..6b85fcabcb3 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 593501939ce..4a202431bc7 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 5bdd4d914c6..abd160242c4 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 a2cab1fe691..6d582784e88 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 00000000000..3fe5d3b7b43 --- /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 5538467aafd..6a548045909 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 9d73cb6ef80..7c1ce5e6a02 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 @@ # 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 f7ee57b1713..358806fb78d 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 83c9dfbe507..4f2b0323078 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 578f5c860c3..a0924100d31 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 9cf9291aa6e..eb22249e0af 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 @@ 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 781dbe9deb4..9511ca02b86 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 e35012a2009..8b84abfbe4b 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 0066e77ea43..0df9c49c393 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 461b083339e..33769b7c018 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 7207df9579f..1a1704f2773 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 @@ IssueAttachmentDetailAPIEndpoint, WorkspaceIssueAPIEndpoint, IssueSearchEndpoint, + IssueRelationListCreateAPIEndpoint, ) # Deprecated url patterns @@ -141,6 +146,11 @@ 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 75b1b17c40f..e8549afb437 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 @@ 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 a91ebc8839a..88c34c37cad 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 2e658443018..fc65e7abdcf 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 c92b27f5912..30b04ed46dd 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 @@ def get(self, request, slug, project_id): # 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 @@ def get(self, request, slug, project_id): # 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 @@ def post(self, request, slug, project_id): 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 @@ def post(self, request, slug, project_id): 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 @@ def patch(self, request, slug, project_id, pk): """ 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 @@ def patch(self, request, slug, project_id, pk): 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 @@ def patch(self, request, slug, project_id, pk): 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 @@ def delete(self, request, slug, project_id, pk): 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 @@ def delete(self, request, slug, project_id, pk): # 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 @@ def get(self, request, slug, project_id): 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 @@ def post(self, request, slug, project_id, cycle_id): 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 @@ def delete(self, request, slug, project_id, cycle_id): 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 @@ def get(self, request, slug, project_id, cycle_id): # 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 @@ def get(self, request, slug, project_id, cycle_id): 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 @@ def post(self, request, slug, project_id, cycle_id): 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 @@ def post(self, request, slug, project_id, cycle_id): ) # 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 @@ def post(self, request, slug, project_id, cycle_id): 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 @@ def get(self, request, slug, project_id, cycle_id, issue_id): 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 @@ def post(self, request, slug, project_id, cycle_id): {"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 00000000000..915cc8e5cc3 --- /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 216b27afc0d..2df2d30699f 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 @@ def post(self, request, slug, project_id): ) # 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 @@ def patch(self, request, slug, project_id, issue_id): # 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 f1263b00902..f1dd6af258e 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 fe32fe3fddd..97e8e7cee0a 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 @@ 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 @@ IssueActivitySerializer, IssueCommentSerializer, IssueLinkSerializer, + IssueRelationCreateSerializer, + IssueRelationResponseSerializer, + IssueRelationSerializer, IssueSerializer, LabelSerializer, IssueAttachmentUploadSerializer, @@ -49,6 +60,7 @@ IssueLinkCreateSerializer, IssueLinkUpdateSerializer, LabelCreateUpdateSerializer, + RelatedIssueSerializer, ) from plane.app.permissions import ( ProjectEntityPermission, @@ -62,6 +74,7 @@ FileAsset, IssueComment, IssueLink, + IssueRelation, Label, Project, ProjectMember, @@ -72,10 +85,12 @@ 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 @@ def put(self, request, slug, project_id): 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 @@ def put(self, request, slug, project_id): 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 @@ def patch(self, request, slug, project_id, pk): 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 @@ def get(self, request, slug, project_id, issue_id): 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 @@ def get(self, request, slug, project_id, issue_id, pk): 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 @@ def get(self, request, slug, project_id, issue_id): 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 @@ def get(self, request, slug, project_id, issue_id): 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 @@ def get(self, request, slug): )[: 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 854bc7ae67e..adb28be0033 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 @@ def post(self, request, slug, project_id): # 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 a4e0f3fe820..61e198b4814 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 @@ def patch(self, request, slug, project_id, pk): {"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 fa735f557d7..5ab0fd1c1fa 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 plane.db.models import ( Cycle, Intake, - IssueUserProperty, Module, Project, DeployBoard, @@ -27,6 +31,11 @@ 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 @@ 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 @@ def get(self, request, slug): 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 @@ def post(self, request, slug): """ 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 @@ def post(self, request, slug): 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 @@ def delete(self, request, slug, project_id): 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 8d2633e6751..eac0ee258f1 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 a5173edc731..f6b4298f668 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 b874cec18b4..02d29d11887 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 e69de29bb2d..917e26db4cb 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 e3277fc4d89..1dcf0d849f4 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 e69de29bb2d..917e26db4cb 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 ddabb4132da..abd81398573 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 849f7ba3ee1..22d27694e9f 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 a2b1a18ff85..9c451ed86ab 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 decorator(view_func): 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 bea878f4c49..844ff4dafbb 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 a8c0f92a27a..55550b27aca 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 8dc791c0cc9..ada16ec3b5a 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 759f27ed6e6..e8a4007ea61 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 @@ 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 13b24d14dd2..ca86e569ffc 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 d14dcacffc6..05c6198f594 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 560cd353813..1de5961015c 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 0d8c855c91f..6457eec50ac 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 89a5efc0679..afdc58116bf 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 b017a03bafa..da6eae6e262 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 b2d65ef8c30..d3343fbe8b1 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 5c78cfa6945..f8efcfce140 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 246461f8f38..023c7d5d545 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 8997f639202..2dc4e8e7218 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 bc75a0ce588..4037dfe1ca0 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 5e3b93ab674..c5af9b9ffe2 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 @@ Issue, IssueActivity, IssueComment, - IssueUserProperty, + ProjectUserProperty, IssueAssignee, IssueSubscriber, IssueLabel, @@ -53,7 +57,7 @@ class Meta: fields = [ "id", "name", - "description", + "description_json", "description_html", "priority", "start_date", @@ -346,9 +350,9 @@ class Meta: 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 b5e2953cc67..7d01284e303 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 58007ec26c4..b4eb4eac540 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 3aecbafda3f..a9251129c32 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 @@ def create(self, validated_data): 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 @@ def create(self, validated_data): # 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 @@ def update(self, instance, validated_data): 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 01569cbc96a..924c48fcfa3 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 @@ ProjectIdentifier, DeployBoard, ProjectPublicMember, + IssueSequence, ) from plane.utils.content_validator import ( validate_html_content, @@ -31,6 +40,9 @@ def validate_name(self, name): 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 @@ def validate_identifier(self, identifier): 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 @@ def get_members(self, obj): 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 cb56cfbe992..0e333a80b7b 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 670667a853f..aeef4ee28fb 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 Meta: "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 bf7ff9727c6..72f72ff71b2 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 2aecebcdec0..74ebde89205 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 ba59f2429c9..608cdad8517 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 3feab4cb548..3fa850b6abd 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 df6ad24984b..2b319418668 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 c74aeddbf2b..145cfdd652d 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 @@ 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 4b7e2b220cd..fd8d200738f 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 f188d087241..2560a3edb59 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 c77a5b6b6aa..4378164f166 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 0bcb4621b24..c7acf534311 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 @@ 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 4972962d879..1255ac08a33 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 dd1efc872a3..970e763d6bb 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 1d809e248f5..436d2277056 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 @@ IssueReactionViewSet, IssueRelationViewSet, IssueSubscriberViewSet, - IssueUserDisplayPropertyEndpoint, + ProjectUserDisplayPropertyEndpoint, IssueViewSet, LabelViewSet, BulkArchiveIssuesEndpoint, @@ -208,13 +212,13 @@ 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 75cbb14d6b6..255f8211ccc 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 0c992d49e98..cd2b3c5a4e8 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 8cac22a2fd8..dd4395c18e7 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 a6dd8d8a8a9..ee850af1d68 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 0bbbd9cf7f4..9d94aa27377 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 b6135ca9596..902b583cb5f 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 ff14d029f2e..9fc17f79aad 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 373d4a70d39..bc110a28dd9 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 063e39c3db4..f3e4ee1de5c 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 e21ec726143..22ac4bc6f81 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 5f781efa7a6..d79d5a74522 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 7a0e5cb3a28..84f7872ec85 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 .issue.base import ( IssueListEndpoint, IssueViewSet, - IssueUserDisplayPropertyEndpoint, + ProjectUserDisplayPropertyEndpoint, BulkDeleteIssuesEndpoint, DeletedIssuesListViewSet, IssuePaginatedViewSet, @@ -161,7 +165,7 @@ 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 1a5b1b34ceb..5ba9a439b45 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 6e9311a1853..2f3f8b5737d 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 2529900b05a..c8e896716b5 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 41985990239..f3163c33146 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 @@ # 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 @@ def patch(self, request: Request, pk: str) -> Response: 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 522d4af7518..5b55a76a611 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 3677537bb1e..62c5f84a20b 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 @@ def entity_asset_save(self, asset_id, entity_type, asset, request): # 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 @@ def entity_asset_save(self, asset_id, entity_type, asset, request): # 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 @@ def entity_asset_delete(self, entity_type, asset, request): 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 @@ def entity_asset_delete(self, entity_type, asset, request): 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 @@ def post(self, request): # 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 @@ def delete(self, request, asset_id): asset.is_deleted = True asset.deleted_at = timezone.now() # get the entity and save the asset id for the request field - self.entity_asset_delete( - entity_type=asset.entity_type, asset=asset, request=request - ) + 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 @@ def entity_asset_save(self, asset_id, entity_type, asset, request): 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 @@ def entity_asset_delete(self, entity_type, asset, request): 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 @@ def post(self, request, slug): 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 @@ def delete(self, request, slug, asset_id): asset.is_deleted = True asset.deleted_at = timezone.now() # get the entity and save the asset id for the request field - self.entity_asset_delete( - entity_type=asset.entity_type, asset=asset, request=request - ) + 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 @@ def post(self, request, slug, project_id): # 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 @@ def patch(self, request, slug, project_id, pk): @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 @@ def delete(self, request, slug, project_id, pk): @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 @@ def post(self, request, slug, project_id, entity_id): # 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 @@ def get_entity_id_field(self, entity_type, entity_id): 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 @@ def post(self, request, slug, asset_id): 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 @@ def post(self, request, slug, asset_id): # 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 0323302c5a3..db5469de585 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 a2f89d53f6c..3738b336717 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 712d71754e5..30a5732ce0a 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 @@ def get_queryset(self): .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 @@ def get_queryset(self): .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 @@ def get_queryset(self): "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 @@ def list(self, request, slug, project_id): # 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 @@ def list(self, request, slug, project_id): @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 @@ def create(self, request, slug, project_id): 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 @@ def create(self, request, slug, project_id): 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 @@ def partial_update(self, request, slug, project_id, pk): 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 @@ def retrieve(self, request, slug, project_id, pk): ) 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 @@ def retrieve(self, request, slug, project_id, pk): 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 @@ def post(self, request, slug, project_id): 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 @@ def patch(self, request, slug, project_id, cycle_id): ) 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 @@ def get(self, request, slug, project_id, cycle_id): 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 @@ def get(self, request, slug, project_id, cycle_id): 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 @@ def get(self, request, slug, project_id, cycle_id): 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 @@ def get(self, request, slug, project_id, cycle_id): ) ) .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 @@ def get(self, request, slug, project_id, cycle_id): .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 @@ def get(self, request, slug, project_id, cycle_id): .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 ad3923b17bd..60996784578 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 97c3c59f7cc..b7ec4dcf3a5 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 f54115a4f2e..4bdc86633d6 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 5f446ff9495..64364ecf470 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 2c554bbc866..013bad2dbf6 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 7dd7828cbdf..d4049aa3cb2 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 @@ def partial_update(self, request, slug, project_id, pk): 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 fdfcd129ae6..8f629564013 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 b8f8589696a..1ac808cf926 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 2207d241929..df027c413b1 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 7a5e7dddf62..bb331802c84 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 @@ 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 @@ IssueReaction, IssueRelation, IssueSubscriber, - IssueUserProperty, + ProjectUserProperty, ModuleIssue, Project, ProjectMember, @@ -723,23 +727,33 @@ def destroy(self, request, slug, project_id, pk=None): 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 @@ def post(self, request, slug, project_id): 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 72a986fea55..34fe0f9e4b9 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 ad0a290801b..05033593e60 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 @@ def get_queryset(self): @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 ee209f9fae8..54902123026 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 fe8a63b1355..c09e1e92442 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 0dfd686d29e..e91ddffec81 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 2fa244dcfb6..b52e07564f6 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 58f3ba4c7eb..c9a1a29b658 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 358271ac875..540c7d6d5c7 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 129ff0dac49..1f234d79156 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 ae429e7504b..97e683f7508 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 672bf4e1ae9..4707d683a7d 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 a11c12d167c..0b7dc27a8a8 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 50daf440adc..ec391afc1aa 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 @@ # 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 @@ def create(self, request, slug, project_id): 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 @@ def stream_data(): 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 @@ def partial_update(self, request, slug, project_id, page_id): 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 1b285c96615..e102bf1d0b9 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 3dd1e3db42b..0a7378c076f 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 @@ 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 @@ def list_detail(self, request, slug): @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 @@ def list(self, request, slug): 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 @@ def list(self, request, slug): "sort_order", "logo_props", "member_role", + "intake_count", "archived_at", "workspace", "cycle_view", @@ -255,8 +263,6 @@ def create(self, request, slug): 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 @@ def create(self, request, slug): 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 cc5b3f4b577..19d8c36bcf7 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 @@ User, WorkspaceMember, Project, - IssueUserProperty, + ProjectUserProperty, ) from plane.db.models.project import ProjectNetwork from plane.utils.host import base_host @@ -160,9 +164,9 @@ def create(self, request, slug): 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 @@ def post(self, request, slug, project_id, pk): 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 3ab7061e15c..7dfe7090013 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.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 @@ def create(self, request, slug, project_id): # 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 @@ def create(self, request, slug, project_id): 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 @@ def partial_update(self, request, slug, project_id, pk): 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 f1e69265323..3bfbecaaff0 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 ac9d783a9ff..737fe641004 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 ce1597a6855..55c232fdf64 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 fc01211792b..9644ceee370 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 72d42010ced..914dffb3b02 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 98fe04c62fa..5ca7aac420f 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 e857c3e08ff..c874f0a4225 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 c27b7adbb26..be43eace2f6 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.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 @@ def create(self, request): 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 @@ def destroy(self, request, *args, **kwargs): # 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 @@ def generate_csv_from_rows(self, 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] 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 73deca0594e..deb86c5c4db 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 c89fe4a7304..aa228ded372 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 8cba3d17000..7f5bb66f675 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 8a8bfed6c96..8217f0fb02e 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 731164eb1c7..ec35aaf4eca 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 48bcf7eba30..cf2ab795a73 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 @@ 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 @@ def create(self, request, slug): 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 @@ def post(self, request, slug, pk): 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 @@ def post(self, request, slug, pk): # 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 @@ def create(self, request): 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 11ca6b9139b..926a504a34b 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 3394cb253f0..67c7637a8c2 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 e61fc70e732..b048481402a 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 @@ def get(self, request, slug): ) .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 @@ def get(self, request, slug): ) .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 @@ def get(self, request, slug): ) .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 @@ def get(self, request, slug): ) .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 @@ def get(self, request, slug): ) .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 82c104573d8..ba971c54f9e 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 0d9c1ef9bb4..a9394766a20 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 3bfc8d22def..a8c5b368d99 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 8ab6c5c9824..9cf1532257b 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 b45c6e410b9..b60ae5e15eb 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 253f715b36c..83e380b9ec1 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 2dd703ffef1..9d3bd6b07a0 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 e69de29bb2d..917e26db4cb 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 e69de29bb2d..917e26db4cb 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 b80555fe16e..b80dfa5002f 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 +from plane.bgtasks.user_activation_email_task import user_activation_email + # Module imports -from plane.db.models import Profile, User, WorkspaceMemberInvite, FileAsset +from plane.db.models import FileAsset, Profile, User, WorkspaceMemberInvite from plane.license.utils.instance_value import get_configuration_value -from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES -from plane.bgtasks.user_activation_email_task import user_activation_email +from plane.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 @@ def __init__(self, request, provider, callback=None): 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 @@ def authenticate(self): 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 @@ def sanitize_email(self, email): 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 @@ def validate_password(self, email): """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 @@ def __check_signup(self, email): # 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 @@ def __check_signup(self, email): 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 @@ def download_and_upload_avatar(self, avatar_url, user): # 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 @@ def save_user_data(self, user): 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 @@ def complete_login_or_signup(self): 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 @@ def complete_login_or_signup(self): # 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 0327289ca26..eee6ea97f6b 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 25a7cf56717..f91565df2e8 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 @@ "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 e906c5a50bd..c8d28762a90 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 d8e423d0e7e..0bef76b2487 100644 --- a/apps/api/plane/authentication/adapter/oauth.py +++ b/apps/api/plane/authentication/adapter/oauth.py @@ -1,20 +1,25 @@ +# 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 -# Module imports -from plane.db.models import Account - -from .base import Adapter from plane.authentication.adapter.error import ( - AuthenticationException, AUTHENTICATION_ERROR_CODES, + AuthenticationException, ) + +# Module imports +from plane.db.models import Account from plane.utils.exception_logger import log_exception +from .base import Adapter + class OauthAdapter(Adapter): def __init__( @@ -74,6 +79,7 @@ def get_user_token(self, data, headers=None): 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 @@ def get_user_response(self): 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 cf5cdca1c96..5a612eaa98c 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 e69de29bb2d..917e26db4cb 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 c367a15d36f..817f898fd43 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 e69de29bb2d..917e26db4cb 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 e69de29bb2d..917e26db4cb 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 c3d19a80e7c..e2c424588a2 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 @@ def __init__(self, request, key=None, code=None, is_signup=False, callback=None) 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 @@ def set_user_data(self): 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 @@ def set_user_data(self): # 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 @@ def set_user_data(self): 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 e7c5cfff956..a6c9883d6b2 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 e69de29bb2d..917e26db4cb 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 ba7d3d16ba3..8c0c3a5db51 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 @@ def set_token_data(self): 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 @@ def set_user_data(self): "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 54c48018eff..363cd722e5e 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,14 +10,15 @@ import pytz import requests -# 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, + AuthenticationException, ) +# Module imports +from plane.authentication.adapter.oauth import OauthAdapter +from plane.license.utils.instance_value import get_configuration_value + class GitHubOAuthProvider(OauthAdapter): token_url = "https://github.com/login/oauth/access_token" @@ -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 @@ def set_token_data(self): "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 @@ def set_user_data(self): 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 de4a3515efb..088987c2379 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 41293782f82..b02eda87de3 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 d245d50b37b..f939ef25cd4 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 862a63c1300..fe2aa0c35c0 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 64b8e654c9f..4bec07db00b 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 415791a879c..d79d54e8a80 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 fe6fdad931a..d573335511f 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 82139b82139..59d4b7d50a8 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 13de4c28744..4641f332c5a 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 bd5ad8501b2..9222791a845 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 @@ 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 2595d2e7566..a9c816ae9ea 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 10457b45a04..97ab24def1b 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 864ff102bc8..3d1954875c4 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 fd12f8b3363..67d25e1ab37 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 @@ def get(self, request): 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 @@ def get(self, request): 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 @@ def get(self, request): 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 4720fc7daa8..82d5f4a0538 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 665af00c19d..5b0435250df 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 0ee81c768cd..3dad1385a85 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 518a5cdea49..9104311a620 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 de0baa71b53..48b54dcccb4 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 @@ def post(self, request, uidb64, token): 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 b8019dac188..9941da3c9fc 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 c5dd1714c5e..086d6b0d3e2 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 @@ def post(self, request): 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 95a5e68dfa2..371fadf3666 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 3d092591add..827348cef23 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 497a1ecc095..04c21678fe0 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 f12498d3b07..1df6a8c619d 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 498916b3441..19c057a0696 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 0f02c1f93e8..daa1b48a6f8 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 df940b3275e..37683d9acf2 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 12cc88f63e7..ed6682d74ae 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 @@ def post(self, request, uidb64, token): 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 aa890f978d4..164c6409bca 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 e69de29bb2d..917e26db4cb 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 845fb50dd24..4b0983138be 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 @@ # 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 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 7f6ca38f0c5..e5fb0aa5479 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 6b23f2571d5..407a67ca699 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 e7ef09e353a..742966a6fbb 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 932a1fce06f..11d90416063 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 390bc160b5f..6740495d83e 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 1402adc41f8..5cf1d52af91 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 @@ # 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 0629db93af2..e8f453e9fff 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 @@ # 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 75b5f22659e..24486999d73 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.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 30b638c84c8..9ec2a0102ab 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 d6eccf73574..e54a754c9ac 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 ffaba9937f0..9ca0548de2b 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 @@ # 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 a886305fd23..032feb02a60 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 1cc303b575e..83a2f72d187 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 d10ebfcbac7..795d5e7efc7 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 06d15705a73..49689e81502 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 761c26bc2fe..221a5a417a4 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 00000000000..4a74e54bc55 --- /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 d8267e69716..eef7adea037 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 @@ # 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 6e571c0b17f..bfb72afa364 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 402d0a3ee02..8c2cfe7a0cb 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 4de2387becf..7b41e3c445c 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 af6014695d1..1efe6bc4612 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 @@ # 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 b8eed5e45a9..86c10e90cb9 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 @@ # 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 eda297ce45b..3d4f9e6e9fc 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 ea745053f70..77f99e91658 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 492564b3cec..f7a2d3999ae 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 @@ # 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 2595d8055b2..81419606a75 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 @@ # 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 667de368c79..48b9c02dba2 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 @@ # 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 3d04a65b71b..6543c3845b8 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.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 @@ 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 442396c7f02..5cf0fbb1908 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 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 f7480b36a69..f293cc16f8b 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 @@ # 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 57ac02ec127..218ba2a7179 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 @@ 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 828f4a6d595..562d04856f5 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 e69de29bb2d..917e26db4cb 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 7d4919d088b..92c55908e99 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 e69de29bb2d..917e26db4cb 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 e69de29bb2d..917e26db4cb 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 5ebe8b74094..3488a986591 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 1c66b3eafcf..502778f1cfb 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 8813f34db2b..ec106795b4e 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 555fe0aa88e..7a39a3a7fcf 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 220576b8f49..c85c1e01763 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 8d5a912e042..3834918d409 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 d9b46524c28..2bd97557874 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 @@ WorkspaceMember, ProjectMember, Project, - IssueUserProperty, + ProjectUserProperty, ) @@ -47,27 +51,18 @@ def handle(self, *args: Any, **options: Any): 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 2b262606a29..70624fbc283 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 9e483f51e3d..5da607c6cd4 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 04e608a3ce1..0aac4bb1531 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 6c9a2cdac1c..a7ee98fa751 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 22841a671b8..103b239b1aa 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 47c28ff739d..79f7eab4e7c 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 8383253541e..067afe23166 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 ec971f83a77..8a9fdbc3d6f 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 13b251de53c..b61d011b25c 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 00000000000..4ffbf3bb7fc --- /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 00000000000..9a18fbafca5 --- /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 00000000000..b9ac71d4709 --- /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 00000000000..38e231e0eb2 --- /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 00000000000..2317a4cdd77 --- /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 00000000000..9a2b39edfc1 --- /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 00000000000..a730808a163 --- /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 00000000000..4357766d448 --- /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 00000000000..73b75123f63 --- /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 be5613b613f..b36269959b5 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 @@ def old_values(self) -> dict[str, Any]: 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 41fd32bd557..5cf9dec2a3e 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 @@ IssueLabel, IssueLink, IssueMention, - IssueUserProperty, IssueReaction, IssueRelation, IssueSequence, @@ -54,6 +57,7 @@ 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 0efcb957f4e..601ef9ea542 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 7d040ebc284..c545860c058 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 ed9879a7331..d309135bcac 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 468af826141..482dc90635a 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 bdffd283d8f..78ea977d911 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 da9c0d69826..b9d8778e08e 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 6c298546a39..0e8de3ce76c 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 adcf7974a12..9254a21ffe9 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 55dbb61df94..2d126da2289 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 9373fb3204c..fb472a69bd4 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 Meta: 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 8ad9daad7af..7abfe63afd4 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 de2b101a05d..1ce29da8750 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 9bcea8cf0bd..24d987bb7a3 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 c3369ae1d05..700d5d8cf74 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 34b40e57d98..2242b4ddd14 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 296c3cf6d67..d98aa292df6 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 ba278497edf..8d84dbe3e46 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 1e8ea469b6d..f1c33f5c2cf 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 d3377f0ad37..d24efc8a23c 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 @@ def get_queryset(self): 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 @@ def save(self, *args, **kwargs): 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]) - - 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 - - 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]) + # Get an exclusive transaction-level lock using the project ID as the lock key + cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key]) + + # 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) + + 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 @@ def save(self, *args, **kwargs): "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 @@ def __str__(self): 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 @@ def log_issue_description_version(cls, issue, user): 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 4f3dc08deca..94eaf50bfe0 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 76ecf10e615..9435e01c655 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 ab62f2df540..d660116fa83 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 fd97a3c9689..c2413585484 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 213954d1498..2c82c5f44b2 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 8495ac9df43..4039b1d2903 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 @@ # 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 @@ def __str__(self): """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 Meta: 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"] - - # Project ordering - if smallest_sort_order is not None: - self.sort_order = smallest_sort_order - 10000 + 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") + + # 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 Meta: 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 42855081bd1..fb368fa1226 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 e884498bf12..52b885ee94e 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 9a85a320d5c..7e8ee8c2cac 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 aeb08b8b2e9..fa56900c3fe 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 @@ # Module imports from .project import ProjectBaseModel - +from plane.db.mixins import SoftDeletionManager class StateGroup(models.TextChoices): BACKLOG = "backlog", "Backlog" @@ -58,14 +62,14 @@ class StateGroup(models.TextChoices): ] -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 157077eb8c1..757cb8ea114 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 ee70032cf42..7f1ab162dab 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 @@ def cover_image_url(self): 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 @@ def save(self, *args, **kwargs): 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 d430cd5f97e..a02b768a39a 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 8872d0bb235..99431ed4225 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 d3470d531ea..80a3e3e3e42 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 e69de29bb2d..917e26db4cb 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 e69de29bb2d..917e26db4cb 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 d5bedc4c082..8878e2aaf82 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 a430b688b70..819757375d3 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 6e0a5941c40..b4a39adcef5 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 4df6901cac9..ebca0e5622e 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 0c6bba46823..63c173e6d44 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 1766f21136d..21abc7013a5 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 c75c62e50f6..1598b3fb689 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 c53b4a48489..b5e35ac72de 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 75dd938e45d..d12473e2047 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 7f30d53fe66..e25276495f1 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 5b70beab9d1..6217cc87fa4 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 @@ def post(self, request): }, ) 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 @@ def post(self, request): 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 d209bd6bf27..8d0d39ac387 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 8bb9535655c..bb9a9e00ee6 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 fed0c5e17e6..a0d52d4912f 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 5d1a2f24bbd..966b3b3e8f9 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 400e98155ae..0cd4aba3b54 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 e69de29bb2d..917e26db4cb 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 055c45d6c85..f7c04b2a4b2 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 e69de29bb2d..917e26db4cb 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 e69de29bb2d..917e26db4cb 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 b3e84dd82d8..43026a45543 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 6717cafd13e..5ad6f7d2017 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 d495240244b..b1a84d846fb 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 1767d8c224d..ff9ebc6b46c 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 4d306924eaf..844a9e181ee 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 e69de29bb2d..917e26db4cb 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 d56766d1e1f..8f43167c15a 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 8901bc814af..279eb217777 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 e69de29bb2d..917e26db4cb 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 9deac8091d3..2037b6aa098 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 68b5c449160..7aa045a69c9 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 d513ee3e36d..b8cf6f9c045 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 @@ def __call__(self, request): 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 @@ def _safe_decode_body(self, content): 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 9807c571568..c4e014df6fe 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 badd0e61154..be966e723aa 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.

    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_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 d719220bfef..00c5c91ef0a 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 e69de29bb2d..917e26db4cb 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 a9e9925c28c..9d651bd1b4c 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 @@ "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", + "django.contrib.staticfiles", # Inhouse apps "plane.analytics", "plane.app", @@ -58,6 +63,7 @@ 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 @@ "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 @@ "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 15f05aa3d7c..dc4135bc137 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 @@ "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 879d0c436d9..7855a52d518 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 b79daeecf30..a1961a0c582 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 8df7ae90601..7f3f90d6508 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 @@ "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 628a3d8e63b..6c7e613f04f 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 01afa62374f..e4a978bd2b1 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 @@ def upload_file( 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 6a75f7904d8..a8e431338b7 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 e69de29bb2d..917e26db4cb 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 6f1e76c51cb..dd178e33445 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 a3fe1029f37..e571ac011d8 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 4b92b06fc3d..9f30a7a8392 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 afa760a5936..617ac08428b 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 444c20d429f..cf22cebbb1e 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 a89846cfc78..51dd1f41d15 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 Meta: 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 53840f0782a..81ba93c1365 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 f79eef686dd..62be19f4f4b 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 184f48b4077..410b408f0b3 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 9b707a3434e..4ecbad80e94 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 4945af96afe..c63dfe2a5a6 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 d9a1f6ec330..06d3a117a1a 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 2a5c30a2212..050aeb4abc5 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 59fda12e291..470f7f7b7d0 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 bb63e669539..5ea7671c251 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 068b8c5c17f..1d58aba421b 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 f8e2c50a446..e5f893bd5b7 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 22acfd15bd2..f70d094debb 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 faabd97ab6f..1749a8fd462 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 9be6a2e107d..cf8cdbdc5c9 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 505c17ba406..72bec30641d 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 7ea2dee91fb..4d9913193ce 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 @@ def create(self, request, anchor, intake_id): # 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 @@ def partial_update(self, request, anchor, intake_id, pk): 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 220fc130734..9e2187466aa 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 @@ def get(self, request, anchor, issue_id): "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 51ddb832e41..f7cde57eb33 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 be612db700b..740bed19f3c 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 7c4628f64ff..2df0166acaf 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 0e19085a03d..168c42624f1 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 c1318660046..05b791475c5 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 00000000000..385ed57aac8 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 00000000000..4a9e5ab8ce3 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 00000000000..396e9327dce 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 00000000000..537562ea710 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 00000000000..970a13f1c6e 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 73d90cd21ba..5f9223043a6 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 577414e63a2..96698696966 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 abfede197c9..870779c42d6 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 cebb768ca53..cd5469caa65 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 e69de29bb2d..917e26db4cb 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 e69de29bb2d..917e26db4cb 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 644fe2bef9b..d0138de8b8e 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 a3a43d90aae..db5340dfdf3 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 e69de29bb2d..917e26db4cb 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 24fac7bb42e..ed071b98cb0 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 1c044f19283..808416b028e 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 38b0f51f3b5..979c5e805c4 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 plane.db.models import ( Project, ProjectMember, - IssueUserProperty, + ProjectUserProperty, State, WorkspaceMember, User, @@ -82,8 +86,8 @@ def test_create_project_valid_data(self, session_client, workspace, create_user) 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 @@ def test_create_project_with_project_lead(self, session_client, workspace, creat 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 47b0497952a..427bad60b64 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 b8cd78361ab..4d39d832fae 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 e69de29bb2d..917e26db4cb 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 c5a671e9af9..1537db79f7e 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 e69de29bb2d..917e26db4cb 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 9886036599a..c153703baac 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 00000000000..2838260e890 --- /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 e69de29bb2d..917e26db4cb 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 5ac71696ac2..9f5439e75cb 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 e69de29bb2d..917e26db4cb 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 98a0b05b24d..37f743d7640 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 26a79751268..405538cfbe2 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 e69de29bb2d..917e26db4cb 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 eac92384b37..59a909eeb7a 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 91cde1c4ad8..a4ebc887522 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 @@ def test_label_serializer_create_valid_data(self, db, workspace): @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 21844c714b8..f59667f701b 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 e69de29bb2d..917e26db4cb 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 fe8cf43f8b1..00856aeecb6 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 e69de29bb2d..917e26db4cb 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 465cb3023b1..82b5b106d01 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 d47e59c4b79..33ddebb921d 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 48465004938..bdc3be799f2 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 4b1062559a6..f5e43408cb8 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 e69de29bb2d..917e26db4cb 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 00000000000..ce06ba92e68 --- /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 12fa39cc030..acd86aca868 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 9a2d9c3a0c3..bf4d1cf2b61 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 da3fd45177d..9ff5db6d908 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 8c45389bdf8..61a572dc00a 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 0d5e64a20b9..1ccc501ddcb 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 10e83b85dab..1b4ede2626f 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 @@ def validate_binary_data(data): "rowspan", "colwidth", "background", - "hideContent", - "hidecontent", "style", }, "td": { @@ -148,8 +148,6 @@ def validate_binary_data(data): "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 37c6e3741e7..7f119b62f9a 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 e175683319c..fdd00cca2c6 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 cedd9d45519..73fe2ccc98d 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 e15ec6771d1..4d923e1c133 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 b09e77101f4..b8b137120ea 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 00000000000..26c6e893752 --- /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 ec934e8892d..79634013822 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 f15e7f119bd..d25d5b1eca4 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 00000000000..f950e94515c --- /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 15d38f6bf96..571f9d36873 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 b0a6f8c38ba..657afeb5caf 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 9e7b1a9d51d..632452a3119 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 75b396cb4eb..ff4df46c7fb 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 fc7c23528b0..611a60fca4f 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 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 @@ def _create_csv_file(self, data: List[List[str]]) -> str: 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 98b2623aed2..e792b3c6ff3 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 4e67c6980c5..eacee3741a0 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 744e3305249..a3bda90b7b9 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 76a96c82c07..cdcf8ac6e1a 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 f7693b40ea0..4d37c2b0b17 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 11ed48f7180..c21560f70f6 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 3e424b6e675..555793dc2a0 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 0099b83d099..721bf4c7afd 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 1b7f908c547..e9b68ba7650 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 1ec004e95ad..ab008796715 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 860e19e0e3b..dafd19179e4 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 18d103b6455..a26f6fe13c3 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 81de0203bba..af86c31e7dc 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 6818ca9bf73..09882ae11c9 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 cf8d8d41fbe..274c6539af9 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 @@ "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 @@ "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 @@ "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 @@ "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 24c6fefda4c..cf267aca241 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 01789c431ef..3a0f171d793 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 8d56bc38936..ea31a529bb4 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 19d65c1112d..ecce5a2d121 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 1e7543d8850..7e5fab8fea3 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 083132f1634..61312448d6e 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 188c54fec3b..643dd778863 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 b2c9ba6b0c0..090d076ecd1 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 @@ CYCLE_VIEW_PARAMETER, FIELDS_PARAMETER, EXPAND_PARAMETER, + ESTIMATE_ID_PARAMETER, ) # Responses @@ -122,6 +127,10 @@ 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 @@ PROJECT_MEMBER_EXAMPLE, CYCLE_ISSUE_EXAMPLE, STICKY_EXAMPLE, + ESTIMATE_EXAMPLE, + ESTIMATE_POINT_EXAMPLE, ) # Helper decorators @@ -153,6 +164,7 @@ user_docs, cycle_docs, work_item_docs, + work_item_relation_docs, label_docs, issue_link_docs, issue_comment_docs, @@ -161,6 +173,8 @@ module_docs, module_issue_docs, state_docs, + estimate_docs, + estimate_point_docs, ) # Schema processing hooks @@ -202,6 +216,7 @@ "CYCLE_VIEW_PARAMETER", "FIELDS_PARAMETER", "EXPAND_PARAMETER", + "ESTIMATE_ID_PARAMETER", # Responses "UNAUTHORIZED_RESPONSE", "FORBIDDEN_RESPONSE", @@ -275,6 +290,10 @@ "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 @@ "PROJECT_MEMBER_EXAMPLE", "CYCLE_ISSUE_EXAMPLE", "STICKY_EXAMPLE", + "ESTIMATE_EXAMPLE", + "ESTIMATE_POINT_EXAMPLE", # Decorators "workspace_docs", "project_docs", @@ -303,6 +324,7 @@ "user_docs", "cycle_docs", "work_item_docs", + "work_item_relation_docs", "label_docs", "issue_link_docs", "issue_comment_docs", @@ -311,6 +333,8 @@ "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 9434956fe8b..6f7459ea2db 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 c1ba9612e5c..7ded9fb10b3 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 f41bdddbcb9..20aff18958a 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 @@ }, ) +# 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 @@ "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 @@ "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 f136324c0b4..20319285b15 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 47db747ac7f..2812892ec91 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 @@ ), ], ) + +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 2a569e37780..cb0f81dce7d 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 167cd0693d3..abc0bbca0cf 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 f3a79475676..5ae4d38150c 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 ede3f116154..f15fb4ca953 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 849f7ba3ee1..22d27694e9f 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 a2b1a18ff85..7b243cbb789 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 bea878f4c49..844ff4dafbb 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 a8c0f92a27a..55550b27aca 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 8dc791c0cc9..ada16ec3b5a 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 00000000000..5e2cf79e83d --- /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 00000000000..394a2bb0fd5 --- /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 00000000000..461a6a5e427 --- /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 00000000000..e4e4bb7623b --- /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 00000000000..31be812cc02 --- /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 bec3d240dd8..e3646eaba14 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 9a66742ed26..81aa3692dbd 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 773608bd3d8..8381d65f9c1 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 03f695fdb10..2d95d590648 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 e69de29bb2d..917e26db4cb 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 a5861f9b5ff..1193cd6ae89 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 28734ad91b6..fe1f8951aee 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 8acb70a7714..c2c42710e57 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 b3051f9ff7b..4c8a7916364 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 b0ffb54e836..865590cb2eb 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 66a1ff1638e..c3149753572 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 b92f9fe5bac..886e8a04127 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 f673c1e6341..29c9b466388 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 c32b399fbc0..a52700fa296 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 d2caa9d7a41..f9a250d3624 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 254408ac573..36aecd60d82 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 619f03992a7..84ba654c10a 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 59c7e0e4dd7..bc779f5acea 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 - - - - - - + - + + {{inviter_first_name}} has invited you to the {{project_name}} project in + {{workspace_name}} on Plane. + + + + - - - +

    Plane Software, Inc.

    + + + + + + + + + + diff --git a/apps/api/templates/emails/notifications/webhook-deactivate.html b/apps/api/templates/emails/notifications/webhook-deactivate.html index 272271f9608..44aca67203a 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 a454d0a3c16..8e0a692ad2a 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 3c1a91ec9a3..c9b61bd433f 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 5c496f52c40..becefc8e114 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 19c06fe17f5..b13e08e8451 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 fb53ab79054..d55ca6e8eb5 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 34a4f6a41ac..f670b66c16b 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 ece29671bbb..900a001375d 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 ca3783f14e5..c86b749860f 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 e9adeb4a4c4..9de1ba44c99 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 8469ad4eb0f..5521c10ff83 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 00000000000..ac239486555 --- /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 1b3b07a7a9f..93ebf725ee6 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 a49016eb1ab..00000000000 --- 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 8cdfc1b32d2..fcf06f82d12 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 a1e82314a40..02aa69ca4cf 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 a8b8270dcb1..4e2bc264c33 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 00000000000..1b220d7234b --- /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 00000000000..92621f3a8cb --- /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 00000000000..f3fe47831db --- /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 00000000000..1f40c4e3587 --- /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 00000000000..003d21f552a --- /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 00000000000..f6c6b599c90 --- /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 00000000000..b55a156d0a8 --- /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 00000000000..0578a49c543 --- /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 1692164d284..d59f8fe99bf 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 aac0eb71268..f23374a792e 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 00000000000..e3085eefa92 --- /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 59e5ec01b32..9a3906bf729 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 68eb52b38ff..3834bbd6b15 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 04a06409127..c3f3242cf35 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 29ef316db64..2b076efacd1 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 9b2f5adac3c..2bfd0b1dd1a 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 89a1156272e..d89ab0aa7c4 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 00000000000..18f40b08984 --- /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 00000000000..fa2a7c68d9d --- /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 00000000000..e9c67fc36a6 --- /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 00000000000..1a95b0ece3e --- /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 272d7543c02..b4c28592152 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 ced70a209b3..705eb12f34a 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 bd8e5cd594d..1cbe7a537ed 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 6c05fb83597..39c941d0976 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 d9dbbc485b2..4d1077c9fe3 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 c60ce9ac7a5..473a3c731e6 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 00000000000..507c6f900a2 --- /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 00000000000..44ff35e67d4 --- /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 a3a901c9c23..9d49437c018 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 00000000000..d9a1624cddc --- /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 9b9acc7b041..14559f27219 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 4c121fa7463..60d4a155aa8 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 013e7f8c198..b14ec17c099 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 c0469987026..c4edf3d5455 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 062638de41f..00000000000 --- 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 b0bca4faf59..00000000000 --- 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 a825b1e6520..0aa9254bcbd 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 9cf1c32deba..9c665ede072 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 2330b903383..87aa8dc1992 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 60a171bcf5a..6dbc2e711e8 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 32cdcad861c..cd19bda4b6b 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 9fcfd8a74b3..e137fdd8700 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 4b473b566b4..02a9a564993 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 981270cc300..463770ec5fa 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 a7f04af7d65..fe504b1edf0 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 36c3d20fadc..1a94ca0f7f6 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 1efe34c51ec..00000000000 --- 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 6fc36cc7408..00000000000 --- 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 95d58029df9..00000000000 --- 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 e91b2d47ea3..00000000000 --- 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 a5fc99eef89..00000000000 --- 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 170d934da20..00000000000 --- 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 710462e13a5..00000000000 --- 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 00000000000..4a9c517b7d4 --- /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 9b0c95c3ea8..a2063fe736d 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 0308471eccb..d31649f789e 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 9ed44db83a7..303f9038e60 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 00000000000..125f6699c4c --- /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 5b99cda2e61..7bf8971fa4a 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)
    -