Compare commits

..
Author SHA1 Message Date
pablohashescobar 1053ff4684 feat: add tour_completed_features to Profile model and implement validation in ProfileSerializer
- Introduced a new JSONField `tour_completed_features` in the Profile model with a default value from `get_default_tour_completed_features`.
- Added validation logic in ProfileSerializer to clean and structure `tour_completed_features` input, ensuring only valid keys are retained.
- Updated ExporterHistory model to change `url` field from URLField to TextField for better flexibility.
- Created a new migration to apply these changes to the database.
2025-10-08 17:21:45 +05:30
3cbb60417c [WEB-4976]chore: propel toast implementation (#7832)
* draft: implementation

* chore: migrated toast from @plane/ui to propel package

* chore: reverted unwanted changes

* fix: lint errors

* chore: added dismiss toast to propel toast

* fix: build and lint errors

* chore: code refactor

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2025-10-07 23:37:10 +05:30
Anmol Singh BhatiaandGitHub d38f4811b6 [WEB-3567] dev: empty state v2 (#7910) 2025-10-07 20:53:49 +05:30
Anmol Singh BhatiaandGitHub 3e82984f92 [WEB-5068] fix: CustomMenu closeOnSelect behavior #7897 2025-10-07 20:52:30 +05:30
SangeethaandGitHub 89ddd1bf42 [WEB-5081] fix: update timezone in user settings #7915 2025-10-07 20:51:44 +05:30
NikhilandGitHub 0b15a32ec6 [WEB-5038] fix: cycle creation in external api endpoint (#7866)
* feat: set default owner for cycle creation if not provided

* Updated CycleListCreateAPIEndpoint to assign the current user as the owner when the 'owned_by' field is not included in the request data.
* Enhanced the CycleCreateSerializer initialization to ensure proper ownership assignment during cycle creation.

* feat: add comprehensive tests for Cycle API endpoints

* Introduced a new test suite for Cycle API endpoints, covering creation, retrieval, updating, and deletion of cycles.
* Implemented tests for various scenarios including successful operations, invalid data handling, and conflict resolution with external IDs.
* Enhanced test coverage for listing cycles with different view filters and verifying cycle metrics annotations.

* feat: enhance CycleCreateSerializer to include ownership assignment

* Added 'owned_by' field to CycleCreateSerializer to specify the user who owns the cycle.
* Updated CycleListCreateAPIEndpoint to remove redundant ownership assignment logic, relying on the serializer to handle default ownership.
* Ensured that if 'owned_by' is not provided, it defaults to the current user during cycle creation.

* fix: correct assertion syntax in CycleListCreateAPIEndpoint tests

* Updated the assertion in the test for successful cycle creation to use the correct syntax for checking the response status code.
* Ensured that the test accurately verifies the expected behavior of the API endpoint.
2025-10-07 18:50:09 +05:30
Anmol Singh BhatiaandGitHub f5eb13f66f [WEB-5086] fix: instance setup screen asset #7914 2025-10-07 18:47:36 +05:30
M. PalanikannanandGitHub c3e8ce8f28 [WIKI-710] [WIKI-717] fix: slash commands and mentions in comments (#7919)
* fix: fixing slash commands and mentions

* fix: tiptap types handled
2025-10-07 18:01:39 +05:30
Yaroslav HalchenkoandGitHub 2ca8620246 Add codespell support (config, workflow to detect/not fix) and make it fix some typos (#7864)
* Add github action to codespell preview on push and PRs

* Add rudimentary codespell config

* [DATALAD RUNCMD] chore: run codespell throughout fixing a few typos interactively

=== Do not change lines below ===
{
 "chain": [],
 "cmd": "codespell -w -i 3 -C 4 ./apps/web/core/components/workspace/delete-workspace-form.tsx ./deployments/cli/community/README.md",
 "exit": 0,
 "extra_inputs": [],
 "inputs": [],
 "outputs": [],
 "pwd": "."
}
^^^ Do not change lines above ^^^

* Adjust coespell regex to ignore all camelCased words

* [DATALAD RUNCMD] chore: run codespell throughout fixing a few  new typos automagically

=== Do not change lines below ===
{
 "chain": [],
 "cmd": "codespell -w",
 "exit": 0,
 "extra_inputs": [],
 "inputs": [],
 "outputs": [],
 "pwd": "."
}
^^^ Do not change lines above ^^^
2025-10-07 17:42:49 +05:30
sriram veeraghantaandGitHub 07ff457663 [PRIME-24] fix: update live server aio build path (#7911)
* fix: udpate live server aio build path

* chore: build path
2025-10-07 17:21:07 +05:30
Jayash TripathyandGitHub d3024f8a7f [WEB-4731] feat: add baseui input component to propel package (#7769)
*  feat: add input component to propel package

* 🚨 fix: lint

* 🚨 fix: lint

* fix: add aria-invalid attribute to Input component for better accessibility

* chore: fix formatting in package.json and reorder imports in popover-menu stories
2025-10-07 16:48:02 +05:30
M. PalanikannanandGitHub a740a9f69d [WIKI-713] fix: extra line created on enter key #7913 2025-10-07 14:22:09 +05:30
guru_sainathandGitHub 298acb1e91 [MOB-1221] dev: updated god mode ui #7879 2025-10-07 00:24:09 +05:30
Martin DagarinandGitHub f9e21c58c0 Deployment AIO fixed live entered FATAL state (#7893) 2025-10-06 23:42:21 +05:30
Dheeraj Kumar KetireddyandGitHub 5c842d592e Chore: Filter backend optimizations (#7900)
* refactor: enhance ComplexFilterBackend and BaseFilterSet for Q object filtering

- Introduced BaseFilterSet to support Q object construction for complex filtering.
- Updated ComplexFilterBackend to utilize Q objects for building querysets.
- Improved error handling and validation in filter methods.
- Refactored filter evaluation logic to streamline query construction.

* fix: improve filter processing in BaseFilterSet to handle empty cleaned_data and optimize filter evaluation

- Added handling for cases where cleaned_data is None or empty, returning an empty Q object.
- Optimized filter evaluation by only processing filters that are provided in the request data.

* update ComplexFilterBackend to pass queryset in filter evaluation
2025-10-06 23:36:16 +05:30
0589ac56d5 [WEB-5048] chore: implements esm exports for all packages (#7816)
* fix: esm module imports for live and editor

* fix: convert all pacakges to export esm and cjs build

* fix: auto export

* fix: translation files formatting

* fix: convert eslint files to cjs files for supporting current config

* fix: code uuid package upgrade

---------

Co-authored-by: Aaron Reisman <aaron.reisman@plane.so>
2025-10-06 21:01:32 +05:30
AaronandGitHub cbcb026e9a [WEB-5083] chore: add stories to propel #7888 2025-10-06 20:48:52 +05:30
Vipin ChaudharyandGitHub 0cca31ef2e [WIKI-707] [WIKI-708] fix: editor floating ui modal (#7909) 2025-10-06 16:30:47 +05:30
sriram veeraghanta 4c8f51568a chore: pmp lock upgrade 2025-10-06 01:23:09 +05:30
sriram veeraghanta 8d14a5a004 chore(deps): added vite to catalog 2025-10-06 00:56:52 +05:30
sriram veeraghanta 97381c5145 chore(deps): pnpm version upgrade 2025-10-06 00:38:32 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4709a0fca4 chore(deps): bump django (#7892)
Bumps the pip group with 1 update in the /apps/api/requirements directory: [django](https://github.com/django/django).


Updates `django` from 4.2.24 to 4.2.25
- [Commits](https://github.com/django/django/compare/4.2.24...4.2.25)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 4.2.25
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-05 19:25:09 +05:30
Prateek ShouryaandGitHub b695f161ef [WEB-5074] fix: remove the filter conditions check from the defaultOpen property to prevent unwanted popups. (#7903) 2025-10-03 19:56:31 +05:30
M. PalanikannanandGitHub 431af01483 [WIKI-704] fix: disable exiting on unhandled rejection/exception #7902 2025-10-03 16:10:21 +05:30
Aaryan KhandelwalandGitHub 269fc0d9e0 [WIKI-705] regression: mentions dropdown selection across editors #7901 2025-10-03 15:53:35 +05:30
Anmol Singh BhatiaandGitHub 3c389e2e50 [WEB-5072] chore: propel combobox improvement #7899 2025-10-03 15:01:21 +05:30
M. PalanikannanandGitHub f6677f252f [WIKI-704] fix: hocuspocus error handling (#7898) 2025-10-03 14:01:38 +05:30
Vipin ChaudharyandGitHub 794271ac17 fix-config_types (#7886) 2025-10-01 16:24:58 +05:30
Aaryan KhandelwalandGitHub 931a48d7d7 [WIKI-569] regression: parser kit extensions mismatch #7885 2025-10-01 15:27:06 +05:30
Vipin ChaudharyandGitHub b37e453b32 [WIKI-699] refactor: editor config (#7850) 2025-10-01 15:00:10 +05:30
Aaryan KhandelwalandGitHub a69c6f1b9c regression: slash commands render logic #7882 2025-10-01 02:44:31 +05:30
M. PalanikannanandGitHub ee68895ad3 [WIKI-682] feat: hocuspocus v3 server migration (#7880)
* fix: hocuspocus v3

* fix: publishing messages across redis extension
2025-09-30 20:55:55 +05:30
e110ef55b8 [WIKI-569] chore: migrate to tiptap v3 (#7526)
* chore: upgrade to tiptap v3

* chore: update starter kit

* chore: tippy to floating-ui migration for mentions

* chore: update remaining floating menus

* chore: update setEditorValue function

* fix: potential bugs

* chore: extract out common floating ui positioning logic

* fix: storage api

* fix: bubble menu

* fix: type errors

* fix: type errors

* chore: upgrade tiptap-markdown package

* fix: mentions close callback

* chore: update bubbling sequence

* chore: update package.json

* chore: update tiptap catalogs

* fix: add error handling

* fix: file plugin types

* chore: update live package.json

* fix: broken lock file

---------

Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-09-30 19:56:12 +05:30
sriram veeraghantaandGitHub 5951372555 [WIKI-679] refactor: live server (#7720) 2025-09-30 19:28:15 +05:30
Prateek ShouryaandGitHub 7ce21a6488 [WEB-4969] feat: add toggle button for work item filters row visibility (#7865)
* [WEB-4969] feat: add toggle button for work item filters row visibility

* fix: improve error handling in filter update and refine visibility condition check

* chore: correct visibility toggle parameter in filter store
2025-09-30 18:19:43 +05:30
sriram veeraghantaandGitHub 992457efd2 [WEB-5050] feat: updated work item urls with issues. (#7871)
* feat: updated work item urls with issues.

* chore: updated urls names

* chore: rename file

* chore: code refactor for url ordering

* fix: renamed issue-atachments to attachments
2025-09-30 17:47:05 +05:30
Vamsi KrishnaandGitHub c45151d5e6 [WEB-4882]feat: suspended users (#7844) 2025-09-30 15:31:56 +05:30
Anmol Singh BhatiaandGitHub 726529044e [WEB-4934] dev: propel button implementation (#7859)
* dev: button component code refactor

* dev: propel button component implementation
2025-09-30 15:31:00 +05:30
Vipin ChaudharyandGitHub 0ad439fa63 [WIKI-696] fix: peek view close on click block menu options (#7861)
* fix: block close

* chore : add toggle option in block menu

* fix: separate methods
2025-09-30 15:29:35 +05:30
Aaryan KhandelwalandGitHub 843faf85b7 [WIKI-668] fix: comments mention #7874 2025-09-30 15:27:41 +05:30
sriram veeraghantaandGitHub ded36785e7 feat: optimize project list external endpoint (#7869) 2025-09-29 20:40:33 +05:30
sriram veeraghantaandGitHub 9237f568dd [WEB-5044] fix: ruff lint and format errors (#7868)
* fix: lint errors

* fix: file formatting

* fix: code refactor
2025-09-29 19:15:32 +05:30
NikhilandGitHub 1fb22bd252 [WEB-4805] fix: upgraded psycopgy packages to fix linting and removed unused imports (#7735)
* chore: update psycopg dependencies to version 3.2.9 in base requirements

* refactor: clean up unused imports across multiple files

* chore: update lxml dependency to version 6.0.0 in base requirements

* style: improve code readability by breaking long lines into multiple lines across several files

* style: enhance readability by breaking long lines in ModuleSerializer docstring
2025-09-29 14:33:50 +05:30
Vihar KuramaandGitHub e891482a97 [WEB-4881] feat(utils): add URL display formatter #7770 2025-09-29 14:33:25 +05:30
sriram veeraghantaandGitHub bedc1fae1f [WEB-5027] fix: replace lodash package with lodash-es (#7856)
* fix: replace lodash pacakge with lodash-es

* fix: lint errors

* fix: import fixes
2025-09-26 17:59:31 +05:30
Anmol Singh BhatiaandGitHub c05a5eb5ee [WEB-4981] fix: analytics portal modal #7858 2025-09-26 14:23:04 +05:30
Anmol Singh BhatiaandGitHub b946b3a315 [WEB-4981] fix: analytics modal portal (#7852) 2025-09-25 20:26:44 +05:30
Vamsi KrishnaandGitHub 19e00b0d4b [WEB-5001]fix: prop name for sub work permissions #7854 2025-09-25 17:51:13 +05:30
Anmol Singh BhatiaandGitHub 7f28cbebcf [WEB-4980] dev: propel modal portal component (#7851) 2025-09-25 15:56:33 +05:30
Vamsi KrishnaandGitHub 586a7a48ba [WEB-4896]feat: filters to project and workspace members list (#7786) 2025-09-24 17:45:43 +05:30
M. PalanikannanandGitHub 85bffaa231 [WIKI-695] fix: tippy width fix #7843 2025-09-24 17:45:18 +05:30
SangeethaandGitHub b5ba0a705f [WEB-4996] fix: accessing NoneType intake #7847 2025-09-24 17:43:28 +05:30
Aaryan KhandelwalandGitHub dce8b75a1e [WIKI-638] fix: peek overview closing while dropdowns are open (#7841) 2025-09-24 17:43:02 +05:30
Bavisetti NarayanandGitHub 0ed49a6989 [WIKI-694] chore: added summary endpoint for pages (#7842)
* chore: added summary endpoint for pages

* chore: updated the filters
2025-09-23 18:42:54 +05:30
Prateek ShouryaandGitHub 47f68e3d3d [WEB-4979] fix: filters row missing for preset workspace views (#7836) 2025-09-22 19:29:41 +05:30
Vipin ChaudharyandGitHub 14e3aace92 [WIKI-623] fix: add block menu to rich text editor (#7813)
* fix : block menu for rich editor

* chore: remove comments

* chore : update selection logic
2025-09-22 18:07:52 +05:30
M. PalanikannanandGitHub 36d328445c [WIKI-650] fix: pane extensions close method moved into hook (#7823)
* fix: pane extensions close method moved into hook

* fix: editor build breaks web everytime in dev mode

* fix: variant of lite text toolbar
2025-09-22 18:04:16 +05:30
Prateek ShouryaandGitHub 9aef5d4aa9 [WEB-4951] [WEB-4884] feat: work item filters revamp (#7810) 2025-09-19 18:27:36 +05:30
Vamsi KrishnaandGitHub e6a7ca4c72 [WEB-4974] fix: table column spanning #7830 2025-09-19 15:58:01 +05:30
Vamsi KrishnaandGitHub a40c73cd22 [WEB-4431]fix: table column distribution #7819 2025-09-18 20:26:09 +05:30
Bavisetti NarayanandGitHub 9ede04f1b3 [WEB-4851] chore: updated activity keys for work item (#7733) 2025-09-18 20:25:34 +05:30
Vamsi KrishnaandGitHub d5e5006aab [WEB-4905]fix: cycle dates update (#7821)
* fix: cycle dates update

* fix: handled date clearing
2025-09-18 20:22:49 +05:30
Vamsi KrishnaandGitHub f7d5ca4f83 [WEB-4953]fix: cycle progress percentage #7826 2025-09-18 20:21:56 +05:30
Vamsi KrishnaandGitHub 97059a2786 [WEB-4952]fix: published view work item description #7828 2025-09-18 20:21:31 +05:30
Vamsi KrishnaandGitHub af6ea40b84 [WEB-4826]chore: reduced spacing for sub-work items list and relations list #7814 2025-09-18 20:15:42 +05:30
Bavisetti NarayanandGitHub 9182c9593b [WIKI-657] refactor: the page permissions in project (#7761) 2025-09-18 20:14:46 +05:30
Jayash TripathyandGitHub f59e557be1 [WEB-4879] refactor: organize stories and remove logs #7765 2025-09-18 20:13:54 +05:30
SangeethaandGitHub e26c506cf9 [WEB-4899] fix: workspace admin cannot delete intake and cycle (#7807)
* fix: permission check on viewset

* chore: check workspace admin

* chore: initiative is_workspace_admin before if condition

* chore: project member check

* fix: if conditions

* chore: add condition for guests to only edit description and name

* fix: use ROLE enum instead of magic numbers

* chore: remove if condition
2025-09-18 20:11:35 +05:30
Lakhan BahetiandGitHub d5c3c0cbe1 [WIKI-632] chore: add extended document editor props (#7783) 2025-09-18 20:11:13 +05:30
NikhilandGitHub f2057cd8fe [WEB-3528] fix: correct member id in modules list showing deleted_at members #7777 2025-09-18 20:10:04 +05:30
NikhilandGitHub 69c688b017 [WEB-4873]: Add webhook log cleanup task and update Celery schedule (#7772) 2025-09-18 20:09:01 +05:30
Prateek ShouryaandGitHub 68d72daa90 [WEB-4964] fix: update onboarding layout and enhance scroll behavior (#7825)
* [WEB-4964] fix: update onboarding layout and enhance scroll behavior
2025-09-18 18:26:06 +05:30
Vamsi KrishnaandGitHub 365d2d902c [WEB-4915]fix: redirection after onboarding completion #7824 2025-09-18 14:10:15 +05:30
Anmol Singh BhatiaandGitHub 696635dbb0 [WEB-4956] fix: onboarding redirect with cache busting and code refactor (#7822)
* fix: resolve onboarding completion redirect with cache-busting

* chore: code refactor
2025-09-17 19:50:37 +05:30
877c117c37 [WEB-4943]fix: next path url redirection (#7817)
* fix: next path url redirection

* fix: enhance URL redirection safety in authentication views

Updated SignInAuthSpaceEndpoint, GitHubCallbackSpaceEndpoint, GitLabCallbackSpaceEndpoint, and GoogleCallbackSpaceEndpoint to include checks for allowed hosts and schemes before redirecting. This improves the security of URL redirection by ensuring only valid URLs are used.

* chore: updated uitl to handle double /

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
2025-09-17 18:52:35 +05:30
NikhilandGitHub 3d06189723 [WEB-4943] refactor: enhance URL validation and redirection logic in authentication views (#7815)
* refactor: enhance URL validation and redirection logic in authentication views

* Updated authentication views (SignInAuthSpaceEndpoint, GitHubCallbackSpaceEndpoint, GitLabCallbackSpaceEndpoint, GoogleCallbackSpaceEndpoint, and MagicSignInSpaceEndpoint) to include url_has_allowed_host_and_scheme checks for safer redirection.
* Improved URL construction by ensuring proper formatting and fallback to base host when necessary.
* Added get_allowed_hosts function to path_validator.py for better host validation.

* refactor: improve comments and clean up code in path_validator.py

* Updated comments for clarity in the get_safe_redirect_url function.
* Removed unnecessary blank line to enhance
2025-09-17 16:13:32 +05:30
NikhilandGitHub 6d3d9e6df7 [WEB-4943]: add url has allowed host or scheme for validating valid redirections (#7809)
* feat: enhance path validation and URL safety in path_validator.py

* Added get_allowed_hosts function to retrieve allowed hosts from settings.
* Updated get_safe_redirect_url to validate URLs against allowed hosts.
* Improved URL construction logic for safer redirection handling.

* feat: enhance URL validation in authentication views

* Added url_has_allowed_host_and_scheme checks in SignUpAuthSpaceEndpoint and MagicSignInSpaceEndpoint for safer redirection.
* Updated redirect logic to fallback to base host if the constructed URL is not allowed.
* Improved overall URL safety and handling in authentication flows.

* fix: improve host extraction in get_allowed_hosts function

* Updated get_allowed_hosts to extract only the host from ADMIN_BASE_URL and SPACE_BASE_URL settings for better URL validation.
* Enhanced overall safety and clarity in allowed hosts retrieval.
2025-09-16 21:37:08 +05:30
Prateek ShouryaandGitHub d521eab22f [WEB-4885] feat: new filters architecture and UI components (#7802)
* feat: add rich filters types

* feat: add rich filters constants

* feat: add rich filters utils

* feat: add rich filters store in shared state package

* feat: add rich filters UI components

* fix: make setLoading optional in loadOptions function for improved flexibility

* chore: minor improvements to rich filters

* fix: formatting
2025-09-16 21:15:08 +05:30
sriramveeraghanta 00e070b509 fix: codeql triggers 2025-09-16 20:46:44 +05:30
NikhilandGitHub 4d17637edf [WEB-4943] refactor: streamline URL construction in authentication views (#7806)
* refactor: streamline URL construction in authentication views

* Updated MagicSignInSpaceEndpoint and MagicSignUpSpaceEndpoint to directly construct redirect URLs using formatted strings instead of the get_safe_redirect_url function.
* Enhanced get_safe_redirect_url to use quote for safer URL encoding of parameters.

* refactor: enhance URL validation and redirection in authentication views

* Added validate_next_path function to improve the safety of redirect URLs in MagicSignInSpaceEndpoint and MagicSignUpSpaceEndpoint.
* Updated URL construction to ensure proper handling of next_path and base_url.
* Streamlined the get_safe_redirect_url function for better parameter encoding.

* refactor: unify URL redirection logic across authentication views

* Introduced validate_next_path function to enhance URL safety in SignInAuthSpaceEndpoint, SignUpAuthSpaceEndpoint, GitHubCallbackSpaceEndpoint, GitLabCallbackSpaceEndpoint, and GoogleCallbackSpaceEndpoint.
* Updated URL construction to directly format the redirect URL, improving clarity and consistency across multiple authentication views.
2025-09-16 18:44:26 +05:30
Anmol Singh BhatiaandGitHub bf45635a7b [WEB-4898] fix: extended sidebar toggle #7797 2025-09-16 15:35:31 +05:30
NikhilandGitHub 56d3a9e049 [WEB-4900] refactor: remove base_host retrieval from authentication views (#7804)
* refactor: remove base_host retrieval from authentication views

* Removed unnecessary base_host retrieval from GitHub, GitLab, and Google callback endpoints.
* Updated MagicSignUpEndpoint to use get_safe_redirect_url for URL construction.
* Refactored MagicSignInSpaceEndpoint to streamline URL redirection logic.

* refactor: streamline URL redirection in MagicSignInSpaceEndpoint

* Removed redundant base_url retrieval from the exception handling in MagicSignInSpaceEndpoint.
* Enhanced the clarity of URL construction by directly using get_safe_redirect_url.
2025-09-16 10:57:20 +05:30
sriram veeraghanta 1f7eef5f81 chore: django import error 2025-09-16 01:24:48 +05:30
sriram veeraghanta bd2272a7da chore (deps): pnpm lockfile overrides update 2025-09-16 00:55:35 +05:30
sriram veeraghanta b9c6bb07bf chore(deps): axios version upgrade to 1.12.0 2025-09-16 00:14:18 +05:30
345dfce25d [WEB-4900]: validated authentication redirection paths (#7798)
* refactor: replace validate_next_path with get_safe_redirect_url for safer URL redirection across authentication views

* refactor: use get_safe_redirect_url for improved URL redirection in SignInAuthSpaceEndpoint and SignUpAuthSpaceEndpoint

* fix: redirect paths

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2025-09-16 00:01:06 +05:30
116c8118ab [WIKI-659] chore: added issue relation and page sort order (#7784)
* chore: added issue relation and page sort order

* feat: add ProjectWebhook model to manage webhooks associated with projects

* chore: updated the migration file

* chore: added migration

* chore: reverted the page base code

* chore: added a variable for sort order in pages

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2025-09-15 18:36:00 +05:30
c3e7cfd16b [WEB-4723] fix: disable project features on project create (#7625)
* fix: disbale project features on project create

* Implement migration 0105 to alter project cycle view fields to Boolean with default values

* Add project view settings in workspace seed task

* Add is_current_version_deprecated field to Instance model

Index user_id field in Session model

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2025-09-12 13:01:03 +05:30
Jayash TripathyandGitHub 9ffc30f7b1 [WEB-4889] refactor: add fill in bar chart bar stroke (#7776)
* ♻️ refactor: add fill in barchart bar stroke

* ♻️ refactor: added fill in the circle
2025-09-12 00:04:16 +05:30
Dheeraj Kumar KetireddyandGitHub b60f12a88e [WEB-4861] fix: update redirection path in MagicSignInEndpoint to home page (#7774)
* fix: update redirection path in MagicSignInEndpoint to home page

* Use / for clarity
2025-09-11 18:56:32 +05:30
Vamsi KrishnaandGitHub 76a0b38dd1 [WEB-4890]fix: dropdown width #7778 2025-09-11 18:56:04 +05:30
SangeethaandGitHub 8ee665f491 [WEB-4875] fix: unsubscribed work items on workspace subscribed work item filter #7775 2025-09-11 17:52:51 +05:30
Vamsi KrishnaandGitHub 85f23b450d [WEB-4852] chore: views refactor (#7729)
* chore: refactored view store and services

* chore: removed unused import

* chore: refactored update view component

* fix: lint errors
2025-09-11 17:09:56 +05:30
Vamsi KrishnaandGitHub 8bf059535a [WEB-4858]chore: updated content for error page (#7766)
* chore: updated content for error page

* chore: updated btn url
2025-09-11 14:22:46 +05:30
Vamsi KrishnaandGitHub 4cfea87108 [WEB-4857] fix: applied filters root update #7750 2025-09-11 14:21:30 +05:30
Vamsi KrishnaandGitHub 4fe2ef706b [WEB-4441]fix: members account type dropdown position #7759 2025-09-11 14:20:10 +05:30
sriram veeraghantaGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
8d354b3eb2 Potential fix for code scanning alert no. 636: URL redirection from remote source (#7760)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-09-11 14:18:13 +05:30
SangeethaandGitHub ec541c2557 [WEB-4854] chore: project admin accesss to workspace admins (#7749)
* chore: project admin accesss to workspace admins

* chore: frontend changes

* chore: remove console.log

* chore: refactor permission decorator

* chore: role enum

* chore: rearrange role_choices
2025-09-11 14:16:36 +05:30
11cd8d11e4 [WIKI-632] chore: accept additional props for document collaborative editor (#7718)
* chore: add collaborative document editor extended props

* fix: additional rich text extension props

* fix: formatting

* chore: add types to the trailing node extension

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2025-09-11 14:15:39 +05:30
Jayash TripathyandGitHub 0f7bfdde91 [WEB-4877] fix: webapp crash because of bar chart (#7763)
* 🔧 fix: dynamic bar color handling and refactored color retrieval logic.

* ♻️  refactor: updated any to Record in getBarColor
2025-09-10 17:44:52 +05:30
Vamsi KrishnaandGitHub ac835bf287 [WEB-4874]fix: calendar picker build errors and styles override (#7762)
* fix: calender picker build errors

* fix: styles override in the picker implementation
2025-09-10 17:10:23 +05:30
sriram veeraghanta db18c3555c Merge branch 'canary' of github.com:makeplane/plane into preview 2025-09-10 14:45:14 +05:30
sriram veeraghantaandCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> b696ae91ed Potential fix for code scanning alert no. 646: Server-side request forgery (#7758)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-09-10 14:44:34 +05:30
sriramveeraghanta 61e91bd09c chore (deps): django and vite version bump 2025-09-10 14:44:34 +05:30
sriram veeraghantaGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
20d773042b Potential fix for code scanning alert no. 646: Server-side request forgery (#7758)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-09-10 14:42:46 +05:30
sriramveeraghanta 30b175108b chore (deps): django and vite version bump 2025-09-10 13:38:30 +05:30
6d116beea3 [WEB-4320] dev: propel emoji reaction component (#7741)
* dev: animated counter added to propel

* chore: animated counter story added

* chore: propel config updated

* chore: code refactor

* dev: emoji reaction and renderer component added to propel

* dev: emoji reaction story added

* chore: propel config updated

* chore: code refactor

* fix: format error

* chore: lint error resolved

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2025-09-10 00:54:47 +05:30
b0db4fcf10 [WEB-4736] dev: propel button (#7746)
* dev: button added to propel

* dev: button story added

* chore: propel config updated

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2025-09-10 00:34:17 +05:30
Anmol Singh BhatiaandGitHub 7e03264758 [WEB-4730] dev: propel context menu component (#7745)
* dev: context menu component added

* dev: context menu story added

* chore: propel config updated
2025-09-10 00:15:55 +05:30
Anmol Singh BhatiaandGitHub 1c8ac3d247 [WEB-4737] dev: propel pill component (#7743)
* dev: pill component added to propel

* dev: pill story added

* chore: propel config updated

* chore: code refactor
2025-09-10 00:15:37 +05:30
3b8bb1effc [WEB-4733] dev: propel toolbar component (#7742)
* dev: toolbar component added to propel

* dev: toolbar story added

* chore: propel config updated

* chore: code refactor

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2025-09-10 00:15:10 +05:30
Anmol Singh BhatiaandGitHub 5a63e6dad2 [WEB-4858] chore: error page ui revamp #7747 2025-09-10 00:03:39 +05:30
Anmol Singh BhatiaandGitHub 45688bdc72 [WEB-4860] dev: propel animated counter component (#7740)
* dev: animated counter added to propel

* chore: animated counter story added

* chore: propel config updated

* chore: code refactor

* chore: code refactor

* fix: format error
2025-09-09 23:51:45 +05:30
Jayash TripathyandGitHub 43b7a6ad0a [WEB-4855] refactor: chart tick improvements (#7732)
* 🚧 WIP: Introduced customTicks prop in BarChart for flexible tick rendering.

*  feat: added customTicks to axis charts for flexible tick rendering

* 🔧 fix: update default bar fill color to black and ensure consistent color usage in BarChart

*  feat: add customTooltipContent prop to LineChart for enhanced tooltip flexibility

* 🔧 fix: update bar fill color handling to support dynamic colors based on data and removed DEFAULT_BAR_FILL_COLOR

* 🔧 fix: correct bar fill color handling in BarChart to ensure proper color assignment for tooltips

* 🔧 fix: update customTicks prop types in TAxisChartProps to use unknown type for better type safety

* 📝 chore: updated translations and cleaned up insight card

* 🚨 fix: lint

* 🔧 fix: remove unused translation key "no_of" from Russian translations
2025-09-09 23:51:13 +05:30
Anmol Singh BhatiaandGitHub 498613284e [WEB-4841] chore: calendar component migration UI to propel (#7730)
* chore: move calendar components and dependencies

* chore: update package configurations

* chore: calendar import updated

* chore: propel config updated

* chore: propel calendar code refactor

* chore: code refactor

* fix: build error
2025-09-09 23:50:51 +05:30
Anmol Singh BhatiaandGitHub 9ab3143a73 [WEB-4816] chore: add label flow (#7716)
* chore: remove create label modal

* fix: label spinner

* chore: add label flow improvements

* chore: code refactor

* chore: code refactor

* chore: code refactor
2025-09-09 23:50:39 +05:30
56cd0fc445 [WEB-4025] fix: external user comment and reaction (#7692)
* chore: reactions types updated

* fix: external user comments

* fix: external user reactions

* chore: added display name for actor

* chore: merge conflicts

* chore: updated the created_by and updated_by

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-09-09 23:50:11 +05:30
Vamsi KrishnaandGitHub 260d9a053d [WEB-4802] fix: module sort order (#7674)
* fix: module sort order

* chore: removed secondary sort for progress

* chore: sort refactor
2025-09-09 23:49:30 +05:30
Vipin ChaudharyandGitHub 34bdc2ad76 [WIKI-636] fix: replace project loader with page loader #7752 2025-09-09 19:27:00 +05:30
99bc4262c5 [WIKI-654] fix: version history to load from html (#7754)
* fix: versions history to load from html

* chore: added json and stripped to store in version history (#7755)

---------

Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
2025-09-09 19:26:19 +05:30
Aaryan KhandelwalandGitHub 49127ebeea [WIKI-653] fix: page content loader #7753 2025-09-09 18:20:31 +05:30
Vamsi KrishnaandGitHub a8a6536379 [WEB-4688]feat: added collapsible to propel (#7643)
* chore: added collapsibl to propel

* fix: export path

* chore: made collapsible a compound component

* fix: lint and format errors

* chore: updated propel exports order and added collapsible to tsdown config
2025-09-09 13:59:00 +05:30
Vamsi KrishnaandGitHub 11b83cf4f2 [WEB-4692]feat: added toast to propel package (#7640)
* * chore: migrated toast to base ui toast
* chore: added toast to propel package

* fix: lint errors

* fix: export path

* fix: lint errors

* chore: imporoved props handling

* fix: lint errors

* chore: updated import paths

* chore: replaced clsx with cn

* * chore: updated tsdown config
* chore: updated exports order
2025-09-09 13:58:11 +05:30
Anmol Singh BhatiaandGitHub fbd48c33f5 [WEB-4729] dev: propel scrollarea (#7748)
* dev: scrollarea added to propel

* dev: scrollarea story added

* chore: propel config updated

* fix: format error
2025-09-09 13:56:28 +05:30
Vamsi KrishnaandGitHub 37ce8a9fe6 [WEB-4800]chore: settings header component refactor #7669 2025-09-09 13:42:40 +05:30
Bavisetti NarayanandGitHub 291101a8e5 chore: added attributes in ol tag (#7736) 2025-09-08 17:17:38 +05:30
Vipin ChaudharyandGitHub 0cb4976e38 [WIKI-647] chore: update image path (#7734)
* chore: update image path

* chore: update asset path
2025-09-08 15:44:56 +05:30
Anmol Singh BhatiaandGitHub 20d139cc9e [WEB-4839] chore: maintenance mode empty state updated (#7728)
* chore: maintenance mode empty state updated

* chore: code refactor

* chore: code refactor

* chore: code refactor
2025-09-05 20:16:39 +05:30
ff181e566f [WEB-4738] feat: add Skeleton component with respective stories in propel (#7704)
*  feat: add Skeleton component with respective stories in propel

* 🚨 fix: lint issues

* ♻️ refactor : enhance Skeleton component with improved props and structure

*  feat: add Skeleton component entry to package.json

* 🚨 fix: lint

* ♻️ refactor: rename Skeleton to skeleton

* fix: rename fixes

* ♻️ refactor: rename skeleton

* 🚨 fix: lint

* ♻️ refactor: add skeleton entry to tsdown config and package.json

* ♻️  refactor: sorted propel exports

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-09-05 15:51:05 +05:30
Jayash TripathyandGitHub a696b6039c [WEB-4835] feat: storybook enhancements (#7702)
*  feat: integrate Storybook addons and enhance Tabs component

- Added `@storybook/addon-designs` and `@storybook/addon-docs` to the project dependencies.
- Enhanced the Tabs component stories to support dynamic tab options.
- Introduced a custom theme for Storybook with a new manager configuration.
- Added a new SVG logo for branding in Storybook.
- Pinned the storybook and related packages version

* ♻️  refactor: update Storybook manager configuration by removing unused import

* ♻️  refactor: enhance Tabs story with size labels and layout adjustments

* 🚨 fix: lint issues

* 🚨 fix: lock file

* ✏️  fix: update brand image path in Storybook manager configuration

* ♻️ refactor: improve Tabs story by ensuring safe default value and enhancing content rendering
2025-09-05 14:55:33 +05:30
Vipin ChaudharyandGitHub af1dcd335e [WIKI-640] chore: refactor link view #7727 2025-09-05 14:14:30 +05:30
Aaryan KhandelwalandGitHub a1500c2206 [WIKI-644] fix: remove unnecessary props from the editor package #7726 2025-09-05 13:50:44 +05:30
Aaryan KhandelwalandGitHub 88f194ca8e [WIKI-637] fix: work item description version history (#7725)
* fix: work item description version history

* chore: add key to the editor
2025-09-04 18:57:50 +05:30
Aaryan KhandelwalandGitHub b68d2ca921 [WIKI-642] fix: editor bubble menu overflow issues #7724 2025-09-04 18:16:03 +05:30
Aaryan KhandelwalandGitHub 78c6aede52 [WIKI-641] fix: overwrite tailwind typography plugin styles #7723 2025-09-04 18:14:48 +05:30
sriramveeraghanta c6c46f9aab Merge branch 'canary' of github.com:makeplane/plane into preview 2025-09-04 14:48:27 +05:30
Anmol Singh BhatiaandGitHub 7d7e37439d [WEB-4840] chore: icon migration from @plane/ui to @plane/propel/icons (#7721)
* chore: move icons from ui package to propel package

* chore: package and tsdown config updated

* chore: migrate all icon imports from @plane/ui to @plane/propel/icons

* chore: remove icon components from @plane/ui package (migrated to
  @plane/propel/icons)

* chore: code refactoring

* chore: migrate remaining icon components from @Plane/ui to @Plane/propel/icons

* fix: lint error

* chore: code refactor
2025-09-04 14:44:53 +05:30
Akshat JainandGitHub d258080ee7 [INFRA-235] fix: docker compose file restart_policy changed to any (#7689)
* chore: update docker-compose.yml to change restart policy condition from 'on-failure' to 'any' and remove SSL variable from variables.env

* fix: update docker-compose.yml to change restart policy condition from 'any' to 'on-failure'
2025-09-04 14:18:45 +05:30
Bavisetti Narayanandsriramveeraghanta c40c7804e7 [WEB-4806] chore: updated html attribute validation #7719 2025-09-04 13:53:21 +05:30
Bavisetti NarayanandGitHub 36c735bede [WEB-4806] chore: updated html attribute validation #7719 2025-09-04 13:52:43 +05:30
Bavisetti Narayanandsriramveeraghanta cab5d20217 [WEB-4806] chore: improved the html validation (#7676)
* chore: improved the html validation

* chore: removed the models changes

* chore: removed extra filters

* chore: changed the protocols
2025-09-03 19:57:37 +05:30
1665 changed files with 89219 additions and 66297 deletions
+7
View File
@@ -0,0 +1,7 @@
[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
+2 -4
View File
@@ -3,11 +3,9 @@ name: "CodeQL"
on:
workflow_dispatch:
push:
branches: ["preview", "master"]
branches: ["preview", "canary", "master"]
pull_request:
branches: ["develop", "preview", "master"]
schedule:
- cron: "53 19 * * 5"
branches: ["preview", "canary", "master"]
jobs:
analyze:
+25
View File
@@ -0,0 +1,25 @@
# 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
+2
View File
@@ -99,3 +99,5 @@ dev-editor
*.rdb.gz
storybook-static
CLAUDE.md
+2
View File
@@ -0,0 +1,2 @@
[tools]
node = "22.18.0"
+2 -1
View File
@@ -2,8 +2,9 @@
import { FC } from "react";
import { useForm } from "react-hook-form";
import { Lightbulb } from "lucide-react";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types";
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
// hooks
@@ -1,13 +1,16 @@
"use client";
import { FC, useState } from "react";
import isEmpty from "lodash/isEmpty";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { Monitor } from "lucide-react";
// 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 { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types";
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { CodeBlock } from "@/components/common/code-block";
@@ -101,7 +104,7 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
},
];
const GITHUB_SERVICE_FIELD: TCopyField[] = [
const GITHUB_COMMON_SERVICE_DETAILS: TCopyField[] = [
{
key: "Origin_URL",
label: "Origin URL",
@@ -121,6 +124,9 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
</>
),
},
];
const GITHUB_SERVICE_DETAILS: TCopyField[] = [
{
key: "Callback_URI",
label: "Callback URI",
@@ -208,12 +214,29 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-custom-background-80/60 rounded-lg">
<div className="pt-2 text-xl font-medium">Plane-provided details for GitHub</div>
{GITHUB_SERVICE_FIELD.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
<div className="pt-2 text-xl font-medium">Plane-provided details for GitHub</div>
<div className="flex flex-col gap-y-4">
{/* common service details */}
<div className="flex flex-col gap-y-4 px-6 py-4 bg-custom-background-80 rounded-lg">
{GITHUB_COMMON_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
{/* web service details */}
<div className="flex flex-col rounded-lg overflow-hidden">
<div className="px-6 py-3 bg-custom-background-80/60 font-medium text-xs uppercase flex items-center gap-x-3 text-custom-text-200">
<Monitor className="w-3 h-3" />
Web
</div>
<div className="px-6 py-4 flex flex-col gap-y-4 bg-custom-background-80">
{GITHUB_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
</div>
</div>
</div>
@@ -6,7 +6,8 @@ import Image from "next/image";
import { useTheme } from "next-themes";
import useSWR from "swr";
// plane internal packages
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
import { resolveGeneralTheme } from "@plane/utils";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
@@ -1,11 +1,12 @@
import { FC, useState } from "react";
import isEmpty from "lodash/isEmpty";
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 { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { CodeBlock } from "@/components/common/code-block";
@@ -4,7 +4,8 @@ import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import useSWR from "swr";
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
// hooks
@@ -1,12 +1,14 @@
"use client";
import { FC, useState } from "react";
import isEmpty from "lodash/isEmpty";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { Monitor } from "lucide-react";
// 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 { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types";
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { CodeBlock } from "@/components/common/code-block";
@@ -90,7 +92,7 @@ export const InstanceGoogleConfigForm: FC<Props> = (props) => {
},
];
const GOOGLE_SERVICE_DETAILS: TCopyField[] = [
const GOOGLE_COMMON_SERVICE_DETAILS: TCopyField[] = [
{
key: "Origin_URL",
label: "Origin URL",
@@ -110,6 +112,9 @@ export const InstanceGoogleConfigForm: FC<Props> = (props) => {
</p>
),
},
];
const GOOGLE_SERVICE_DETAILS: TCopyField[] = [
{
key: "Callback_URI",
label: "Callback URI",
@@ -195,12 +200,29 @@ export const InstanceGoogleConfigForm: FC<Props> = (props) => {
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-custom-background-80/60 rounded-lg">
<div className="pt-2 text-xl font-medium">Plane-provided details for Google</div>
{GOOGLE_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
<div className="pt-2 text-xl font-medium">Plane-provided details for Google</div>
<div className="flex flex-col gap-y-4">
{/* common service details */}
<div className="flex flex-col gap-y-4 px-6 py-4 bg-custom-background-80 rounded-lg">
{GOOGLE_COMMON_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
{/* web service details */}
<div className="flex flex-col rounded-lg overflow-hidden">
<div className="px-6 py-3 bg-custom-background-80/60 font-medium text-xs uppercase flex items-center gap-x-3 text-custom-text-200">
<Monitor className="w-3 h-3" />
Web
</div>
<div className="px-6 py-4 flex flex-col gap-y-4 bg-custom-background-80">
{GOOGLE_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
</div>
</div>
</div>
@@ -4,7 +4,8 @@ import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import useSWR from "swr";
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
// hooks
@@ -4,8 +4,9 @@ import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane internal packages
import { setPromiseToast } from "@plane/propel/toast";
import { TInstanceConfigurationKeys } from "@plane/types";
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
import { Loader, ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useInstance } from "@/hooks/store";
@@ -3,9 +3,11 @@
import React, { FC, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
// types
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types";
// ui
import { Button, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui";
import { CustomSelect } from "@plane/ui";
// components
import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input";
// hooks
@@ -3,7 +3,8 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { Loader, setToast, TOAST_TYPE, ToggleSwitch } from "@plane/ui";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// hooks
import { useInstance } from "@/hooks/store";
// components
@@ -1,9 +1,10 @@
import React, { FC, useEffect, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
// plane imports
import { Button } from "@plane/propel/button";
import { InstanceService } from "@plane/services";
// ui
import { Button, Input } from "@plane/ui";
import { Input } from "@plane/ui";
type Props = {
isOpen: boolean;
@@ -4,9 +4,11 @@ import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { Telescope } from "lucide-react";
// types
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { IInstance, IInstanceAdmin } from "@plane/types";
// ui
import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
import { Input, ToggleSwitch } from "@plane/ui";
// components
import { ControllerInput } from "@/components/common/controller-input";
import { useInstance } from "@/hooks/store";
@@ -1,8 +1,9 @@
"use client";
import { FC } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types";
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ControllerInput } from "@/components/common/controller-input";
// hooks
@@ -7,8 +7,8 @@ import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
import { Transition } from "@headlessui/react";
// plane internal packages
import { WEB_BASE_URL } from "@plane/constants";
import { DiscordIcon, GithubIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { DiscordIcon, GithubIcon } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useTheme } from "@/hooks/store";
@@ -5,8 +5,8 @@ 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 { WorkspaceIcon } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useTheme } from "@/hooks/store";
@@ -4,10 +4,12 @@ import { useRouter } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
// plane imports
import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { InstanceWorkspaceService } from "@plane/services";
import { IWorkspace } from "@plane/types";
// components
import { Button, CustomSelect, getButtonStyling, Input, setToast, TOAST_TYPE } from "@plane/ui";
import { CustomSelect, Input } from "@plane/ui";
// hooks
import { useWorkspace } from "@/hooks/store";
@@ -6,8 +6,11 @@ import Link from "next/link";
import useSWR from "swr";
import { Loader as LoaderIcon } from "lucide-react";
// types
import { Button, getButtonStyling } from "@plane/propel/button";
import { setPromiseToast } from "@plane/propel/toast";
import { TInstanceConfigurationKeys } from "@plane/types";
import { Button, getButtonStyling, Loader, setPromiseToast, ToggleSwitch } from "@plane/ui";
import { Loader, ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { WorkspaceListItem } from "@/components/workspace/list-item";
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { PlaneLockup } from "@plane/ui";
import { PlaneLockup } from "@plane/propel/icons";
export const AuthHeader = () => (
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
+2 -1
View File
@@ -5,8 +5,9 @@ import { useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { AuthService } from "@plane/services";
import { Button, Input, Spinner } from "@plane/ui";
import { Input, Spinner } from "@plane/ui";
// components
import { Banner } from "@/components/common/banner";
// local components
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useTheme } from "next-themes";
import { Toast } from "@plane/ui";
import { Toast } from "@plane/propel/toast";
import { resolveGeneralTheme } from "@plane/utils";
export const ToastWithTheme = () => {
@@ -4,7 +4,7 @@ import React from "react";
// icons
import { SquareArrowOutUpRight } from "lucide-react";
// plane internal packages
import { getButtonStyling } from "@plane/ui";
import { getButtonStyling } from "@plane/propel/button";
import { cn } from "@plane/utils";
export const UpgradeButton: React.FC = () => (
@@ -6,8 +6,9 @@ import Link from "next/link";
// icons
import { Settings2 } from "lucide-react";
// plane internal packages
import { getButtonStyling } from "@plane/propel/button";
import { 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";
@@ -6,8 +6,9 @@ import Link from "next/link";
// icons
import { Settings2 } from "lucide-react";
// plane internal packages
import { getButtonStyling } from "@plane/propel/button";
import { 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";
@@ -6,8 +6,9 @@ import Link from "next/link";
// icons
import { Settings2 } from "lucide-react";
// plane internal packages
import { getButtonStyling } from "@plane/propel/button";
import { 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";
@@ -5,7 +5,7 @@ import Link from "next/link";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button, getButtonStyling } from "@plane/ui";
import { Button, getButtonStyling } from "@plane/propel/button";
type Props = {
isOpen: boolean;
@@ -3,8 +3,8 @@
import React from "react";
// ui
import { Copy } from "lucide-react";
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// icons
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
type Props = {
label: string;
@@ -2,7 +2,7 @@
import React from "react";
import Image from "next/image";
import { Button } from "@plane/ui";
import { Button } from "@plane/propel/button";
type Props = {
title: string;
@@ -3,7 +3,7 @@ import { FC } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import { useTheme } from "next-themes";
import { Button } from "@plane/ui";
import { Button } from "@plane/propel/button";
// assets
import { AuthHeader } from "@/app/(all)/(home)/auth-header";
import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg";
@@ -3,7 +3,7 @@
import { FC } from "react";
import Image from "next/image";
import Link from "next/link";
import { Button } from "@plane/ui";
import { Button } from "@plane/propel/button";
// assets
import PlaneTakeOffImage from "@/public/images/plane-takeoff.png";
@@ -6,8 +6,9 @@ import { useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { AuthService } from "@plane/services";
import { Button, Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
import { Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// components
import { AuthHeader } from "@/app/(all)/(home)/auth-header";
@@ -6,7 +6,7 @@ import Image from "next/image";
import Link from "next/link";
import { useTheme as nextUseTheme } from "next-themes";
// ui
import { Button, getButtonStyling } from "@plane/ui";
import { Button, getButtonStyling } from "@plane/propel/button";
import { resolveGeneralTheme } from "@plane/utils";
// hooks
import { useTheme } from "@/hooks/store";
+1 -1
View File
@@ -1,4 +1,4 @@
import set from "lodash/set";
import { set } from "lodash-es";
import { observable, action, computed, makeObservable, runInAction } from "mobx";
// plane internal packages
import { EInstanceStatus, TInstanceStatus } from "@plane/constants";
+1 -1
View File
@@ -1,4 +1,4 @@
import set from "lodash/set";
import { set } from "lodash-es";
import { action, observable, runInAction, makeObservable, computed } from "mobx";
// plane imports
import { InstanceWorkspaceService } from "@plane/services";
+2 -3
View File
@@ -27,7 +27,7 @@
"@plane/utils": "workspace:*",
"autoprefixer": "10.4.14",
"axios": "catalog:",
"lodash": "catalog:",
"lodash-es": "catalog:",
"lucide-react": "catalog:",
"mobx": "catalog:",
"mobx-react": "catalog:",
@@ -45,11 +45,10 @@
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/lodash": "catalog:",
"@types/lodash-es": "catalog:",
"@types/node": "18.16.1",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/uuid": "^9.0.8",
"typescript": "catalog:"
}
}
+21 -21
View File
@@ -111,6 +111,27 @@
--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"],
@@ -221,27 +242,6 @@
--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 */
/* 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="dark-contrast"] {
+1 -1
View File
@@ -9,4 +9,4 @@ class ApiConfig(AppConfig):
try:
import plane.utils.openapi.auth # noqa
except ImportError:
pass
pass
+2 -6
View File
@@ -46,9 +46,7 @@ class AssetUpdateSerializer(serializers.Serializer):
and upload confirmation for S3-based file storage workflows.
"""
attributes = serializers.JSONField(
required=False, help_text="Additional attributes to update for the asset"
)
attributes = serializers.JSONField(required=False, help_text="Additional attributes to update for the asset")
class GenericAssetUploadSerializer(serializers.Serializer):
@@ -85,9 +83,7 @@ class GenericAssetUpdateSerializer(serializers.Serializer):
upload completion marking and metadata finalization.
"""
is_uploaded = serializers.BooleanField(
default=True, help_text="Whether the asset has been successfully uploaded"
)
is_uploaded = serializers.BooleanField(default=True, help_text="Whether the asset has been successfully uploaded")
class FileAssetSerializer(BaseSerializer):
+4 -7
View File
@@ -29,7 +29,8 @@ class BaseSerializer(serializers.ModelSerializer):
"""
Adjust the serializer's fields based on the provided 'fields' list.
:param fields: List or dictionary specifying which fields to include in the serializer.
:param fields: List or dictionary specifying which
fields to include in the serializer.
:return: The updated fields for the serializer.
"""
# Check each field_name in the provided fields.
@@ -102,13 +103,9 @@ class BaseSerializer(serializers.ModelSerializer):
# Check if field in expansion then expand the field
if expand in expansion:
if isinstance(response.get(expand), list):
exp_serializer = expansion[expand](
getattr(instance, expand), many=True
)
exp_serializer = expansion[expand](getattr(instance, expand), many=True)
else:
exp_serializer = expansion[expand](
getattr(instance, expand)
)
exp_serializer = expansion[expand](getattr(instance, expand))
response[expand] = exp_serializer.data
else:
# You might need to handle this case differently
+16 -14
View File
@@ -4,7 +4,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.db.models import Cycle, CycleIssue
from plane.db.models import Cycle, CycleIssue, User
from plane.utils.timezone_converter import convert_to_utc
@@ -16,6 +16,13 @@ class CycleCreateSerializer(BaseSerializer):
and UTC normalization for time-bound iteration planning and sprint management.
"""
owned_by = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(),
required=False,
allow_null=True,
help_text="User who owns the cycle. If not provided, defaults to the current user.",
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
project = self.context.get("project")
@@ -55,14 +62,9 @@ class CycleCreateSerializer(BaseSerializer):
):
raise serializers.ValidationError("Start date cannot exceed end date")
if (
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
):
if data.get("start_date", None) is not None and data.get("end_date", None) is not None:
project_id = self.initial_data.get("project_id") or (
self.instance.project_id
if self.instance and hasattr(self.instance, "project_id")
else None
self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None
)
if not project_id:
@@ -77,6 +79,10 @@ class CycleCreateSerializer(BaseSerializer):
date=str(data.get("end_date", None).date()),
project_id=project_id,
)
if not data.get("owned_by"):
data["owned_by"] = self.context["request"].user
return data
@@ -166,9 +172,7 @@ class CycleIssueRequestSerializer(serializers.Serializer):
cycle assignment and sprint planning workflows.
"""
issues = serializers.ListField(
child=serializers.UUIDField(), help_text="List of issue IDs to add to the cycle"
)
issues = serializers.ListField(child=serializers.UUIDField(), help_text="List of issue IDs to add to the cycle")
class TransferCycleIssueRequestSerializer(serializers.Serializer):
@@ -179,6 +183,4 @@ class TransferCycleIssueRequestSerializer(serializers.Serializer):
and relationship updates for sprint reallocation workflows.
"""
new_cycle_id = serializers.UUIDField(
help_text="ID of the target cycle to transfer issues to"
)
new_cycle_id = serializers.UUIDField(help_text="ID of the target cycle to transfer issues to")
+4 -10
View File
@@ -1,4 +1,4 @@
# Module improts
# Module imports
from .base import BaseSerializer
from .issue import IssueExpandSerializer
from plane.db.models import IntakeIssue, Issue
@@ -98,9 +98,7 @@ class IntakeIssueUpdateSerializer(BaseSerializer):
and embedded issue updates for issue queue processing workflows.
"""
issue = IssueForIntakeSerializer(
required=False, help_text="Issue data to update in the intake issue"
)
issue = IssueForIntakeSerializer(required=False, help_text="Issue data to update in the intake issue")
class Meta:
model = IntakeIssue
@@ -132,9 +130,5 @@ class IssueDataSerializer(serializers.Serializer):
"""
name = serializers.CharField(max_length=255, help_text="Issue name")
description_html = serializers.CharField(
required=False, allow_null=True, help_text="Issue description HTML"
)
priority = serializers.ChoiceField(
choices=Issue.PRIORITY_CHOICES, default="none", help_text="Issue priority"
)
description_html = serializers.CharField(required=False, allow_null=True, help_text="Issue description HTML")
priority = serializers.ChoiceField(choices=Issue.PRIORITY_CHOICES, default="none", help_text="Issue priority")
+24 -68
View File
@@ -43,21 +43,18 @@ class IssueSerializer(BaseSerializer):
Comprehensive work item serializer with full relationship management.
Handles complete work item lifecycle including assignees, labels, validation,
and related model updates. Supports dynamic field expansion and HTML content processing.
and related model updates. Supports dynamic field expansion and HTML content
processing.
"""
assignees = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(
queryset=User.objects.values_list("id", flat=True)
),
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.values_list("id", flat=True)),
write_only=True,
required=False,
)
labels = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(
queryset=Label.objects.values_list("id", flat=True)
),
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.values_list("id", flat=True)),
write_only=True,
required=False,
)
@@ -89,13 +86,9 @@ class IssueSerializer(BaseSerializer):
# Validate description content for security
if data.get("description_html"):
is_valid, error_msg, sanitized_html = validate_html_content(
data["description_html"]
)
is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
raise serializers.ValidationError({"error": "html content is not valid"})
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
@@ -103,9 +96,7 @@ class IssueSerializer(BaseSerializer):
if data.get("description_binary"):
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
# Validate assignees are from project
if data.get("assignees", []):
@@ -125,13 +116,9 @@ class IssueSerializer(BaseSerializer):
# Check state is from the project only else raise validation error
if (
data.get("state")
and not State.objects.filter(
project_id=self.context.get("project_id"), pk=data.get("state").id
).exists()
and not State.objects.filter(project_id=self.context.get("project_id"), pk=data.get("state").id).exists()
):
raise serializers.ValidationError(
"State is not valid please pass a valid state_id"
)
raise serializers.ValidationError("State is not valid please pass a valid state_id")
# Check parent issue is from workspace as it can be cross workspace
if (
@@ -142,9 +129,7 @@ class IssueSerializer(BaseSerializer):
pk=data.get("parent").id,
).exists()
):
raise serializers.ValidationError(
"Parent is not valid issue_id please pass a valid issue_id"
)
raise serializers.ValidationError("Parent is not valid issue_id please pass a valid issue_id")
if (
data.get("estimate_point")
@@ -154,9 +139,7 @@ class IssueSerializer(BaseSerializer):
pk=data.get("estimate_point").id,
).exists()
):
raise serializers.ValidationError(
"Estimate point is not valid please pass a valid estimate_point_id"
)
raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id")
return data
@@ -172,14 +155,10 @@ class IssueSerializer(BaseSerializer):
if not issue_type:
# Get default issue type
issue_type = IssueType.objects.filter(
project_issue_types__project_id=project_id, is_default=True
).first()
issue_type = IssueType.objects.filter(project_issue_types__project_id=project_id, is_default=True).first()
issue_type = issue_type
issue = Issue.objects.create(
**validated_data, project_id=project_id, type=issue_type
)
issue = Issue.objects.create(**validated_data, project_id=project_id, type=issue_type)
# Issue Audit Users
created_by_id = issue.created_by_id
@@ -311,35 +290,26 @@ class IssueSerializer(BaseSerializer):
data["assignees"] = UserLiteSerializer(
User.objects.filter(
pk__in=IssueAssignee.objects.filter(issue=instance).values_list(
"assignee_id", flat=True
)
pk__in=IssueAssignee.objects.filter(issue=instance).values_list("assignee_id", flat=True)
),
many=True,
).data
else:
data["assignees"] = [
str(assignee)
for assignee in IssueAssignee.objects.filter(
issue=instance
).values_list("assignee_id", flat=True)
for assignee in IssueAssignee.objects.filter(issue=instance).values_list("assignee_id", flat=True)
]
if "labels" in self.fields:
if "labels" in self.expand:
data["labels"] = LabelSerializer(
Label.objects.filter(
pk__in=IssueLabel.objects.filter(issue=instance).values_list(
"label_id", flat=True
)
pk__in=IssueLabel.objects.filter(issue=instance).values_list("label_id", flat=True)
),
many=True,
).data
else:
data["labels"] = [
str(label)
for label in IssueLabel.objects.filter(issue=instance).values_list(
"label_id", flat=True
)
str(label) for label in IssueLabel.objects.filter(issue=instance).values_list("label_id", flat=True)
]
return data
@@ -451,12 +421,8 @@ class IssueLinkCreateSerializer(BaseSerializer):
# Validation if url already exists
def create(self, validated_data):
if IssueLink.objects.filter(
url=validated_data.get("url"), issue_id=validated_data.get("issue_id")
).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
if IssueLink.objects.filter(url=validated_data.get("url"), issue_id=validated_data.get("issue_id")).exists():
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
return IssueLink.objects.create(**validated_data)
@@ -477,15 +443,11 @@ class IssueLinkUpdateSerializer(IssueLinkCreateSerializer):
def update(self, instance, validated_data):
if (
IssueLink.objects.filter(
url=validated_data.get("url"), issue_id=instance.issue_id
)
IssueLink.objects.filter(url=validated_data.get("url"), issue_id=instance.issue_id)
.exclude(pk=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
return super().update(instance, validated_data)
@@ -676,17 +638,13 @@ class IssueExpandSerializer(BaseSerializer):
expand = self.context.get("expand", [])
if "labels" in expand:
# Use prefetched data
return LabelLiteSerializer(
[il.label for il in obj.label_issue.all()], many=True
).data
return LabelLiteSerializer([il.label for il in obj.label_issue.all()], many=True).data
return [il.label_id for il in obj.label_issue.all()]
def get_assignees(self, obj):
expand = self.context.get("expand", [])
if "assignees" in expand:
return UserLiteSerializer(
[ia.assignee for ia in obj.issue_assignee.all()], many=True
).data
return UserLiteSerializer([ia.assignee for ia in obj.issue_assignee.all()], many=True).data
return [ia.assignee_id for ia in obj.issue_assignee.all()]
class Meta:
@@ -734,8 +692,6 @@ class IssueSearchSerializer(serializers.Serializer):
id = serializers.CharField(required=True, help_text="Issue ID")
name = serializers.CharField(required=True, help_text="Issue name")
sequence_id = serializers.CharField(required=True, help_text="Issue sequence ID")
project__identifier = serializers.CharField(
required=True, help_text="Project identifier"
)
project__identifier = serializers.CharField(required=True, help_text="Project identifier")
project_id = serializers.CharField(required=True, help_text="Project ID")
workspace__slug = serializers.CharField(required=True, help_text="Workspace slug")
+20 -22
View File
@@ -17,8 +17,9 @@ class ModuleCreateSerializer(BaseSerializer):
"""
Serializer for creating modules with member validation and date checking.
Handles module creation including member assignment validation, date range verification,
and duplicate name prevention for feature-based project organization setup.
Handles module creation including member assignment validation, date range
verification, and duplicate name prevention for feature-based
project organization setup.
"""
members = serializers.ListField(
@@ -75,9 +76,15 @@ class ModuleCreateSerializer(BaseSerializer):
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if Module.objects.filter(name=module_name, project_id=project_id).exists():
module = Module.objects.filter(name=module_name, project_id=project_id).first()
if module:
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
{
"id": str(module.id),
"code": "MODULE_NAME_ALREADY_EXISTS",
"error": "Module with this name already exists",
"message": "Module with this name already exists",
}
)
module = Module.objects.create(**validated_data, project_id=project_id)
@@ -105,8 +112,9 @@ class ModuleUpdateSerializer(ModuleCreateSerializer):
"""
Serializer for updating modules with enhanced validation and member management.
Extends module creation with update-specific validations including member reassignment,
name conflict checking, and relationship management for module modifications.
Extends module creation with update-specific validations including
member reassignment, name conflict checking,
and relationship management for module modifications.
"""
class Meta(ModuleCreateSerializer.Meta):
@@ -121,14 +129,8 @@ class ModuleUpdateSerializer(ModuleCreateSerializer):
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if (
Module.objects.filter(name=module_name, project=instance.project)
.exclude(id=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
if Module.objects.filter(name=module_name, project=instance.project).exclude(id=instance.id).exists():
raise serializers.ValidationError({"error": "Module with this name already exists"})
if members is not None:
ModuleMember.objects.filter(module=instance).delete()
@@ -155,8 +157,8 @@ class ModuleSerializer(BaseSerializer):
"""
Comprehensive module serializer with work item metrics and member management.
Provides complete module data including work item counts by status, member relationships,
and progress tracking for feature-based project organization.
Provides complete module data including work item counts by status, member
relationships, and progress tracking for feature-based project organization.
"""
members = serializers.ListField(
@@ -238,12 +240,8 @@ class ModuleLinkSerializer(BaseSerializer):
# Validation if url already exists
def create(self, validated_data):
if ModuleLink.objects.filter(
url=validated_data.get("url"), module_id=validated_data.get("module_id")
).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
if ModuleLink.objects.filter(url=validated_data.get("url"), module_id=validated_data.get("module_id")).exists():
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
return ModuleLink.objects.create(**validated_data)
+14 -42
View File
@@ -66,9 +66,7 @@ class ProjectCreateSerializer(BaseSerializer):
workspace_id=self.context["workspace_id"],
member_id=data.get("project_lead"),
).exists():
raise serializers.ValidationError(
"Project lead should be a user in the workspace"
)
raise serializers.ValidationError("Project lead should be a user in the workspace")
if data.get("default_assignee", None) is not None:
# Check if the default assignee is a member of the workspace
@@ -76,9 +74,7 @@ class ProjectCreateSerializer(BaseSerializer):
workspace_id=self.context["workspace_id"],
member_id=data.get("default_assignee"),
).exists():
raise serializers.ValidationError(
"Default assignee should be a user in the workspace"
)
raise serializers.ValidationError("Default assignee should be a user in the workspace")
return data
@@ -87,14 +83,10 @@ class ProjectCreateSerializer(BaseSerializer):
if identifier == "":
raise serializers.ValidationError(detail="Project Identifier is required")
if ProjectIdentifier.objects.filter(
name=identifier, workspace_id=self.context["workspace_id"]
).exists():
if ProjectIdentifier.objects.filter(name=identifier, workspace_id=self.context["workspace_id"]).exists():
raise serializers.ValidationError(detail="Project Identifier is taken")
project = Project.objects.create(
**validated_data, workspace_id=self.context["workspace_id"]
)
project = Project.objects.create(**validated_data, workspace_id=self.context["workspace_id"])
return project
@@ -119,25 +111,17 @@ class ProjectUpdateSerializer(ProjectCreateSerializer):
"""Update a project"""
if (
validated_data.get("default_state", None) is not None
and not State.objects.filter(
project=instance, id=validated_data.get("default_state")
).exists()
and not State.objects.filter(project=instance, id=validated_data.get("default_state")).exists()
):
# Check if the default state is a state in the project
raise serializers.ValidationError(
"Default state should be a state in the project"
)
raise serializers.ValidationError("Default state should be a state in the project")
if (
validated_data.get("estimate", None) is not None
and not Estimate.objects.filter(
project=instance, id=validated_data.get("estimate")
).exists()
and not Estimate.objects.filter(project=instance, id=validated_data.get("estimate")).exists()
):
# Check if the estimate is a estimate in the project
raise serializers.ValidationError(
"Estimate should be a estimate in the project"
)
raise serializers.ValidationError("Estimate should be a estimate in the project")
return super().update(instance, validated_data)
@@ -182,9 +166,7 @@ class ProjectSerializer(BaseSerializer):
member_id=data.get("project_lead"),
).exists()
):
raise serializers.ValidationError(
"Project lead should be a user in the workspace"
)
raise serializers.ValidationError("Project lead should be a user in the workspace")
# Check default assignee should be a member of the workspace
if (
@@ -194,23 +176,17 @@ class ProjectSerializer(BaseSerializer):
member_id=data.get("default_assignee"),
).exists()
):
raise serializers.ValidationError(
"Default assignee should be a user in the workspace"
)
raise serializers.ValidationError("Default assignee should be a user in the workspace")
# Validate description content for security
if "description_html" in data and data["description_html"]:
if isinstance(data["description_html"], dict):
is_valid, error_msg, sanitized_html = validate_html_content(
str(data["description_html"])
)
is_valid, error_msg, sanitized_html = validate_html_content(str(data["description_html"]))
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
raise serializers.ValidationError({"error": "html content is not valid"})
return data
@@ -219,14 +195,10 @@ class ProjectSerializer(BaseSerializer):
if identifier == "":
raise serializers.ValidationError(detail="Project Identifier is required")
if ProjectIdentifier.objects.filter(
name=identifier, workspace_id=self.context["workspace_id"]
).exists():
if ProjectIdentifier.objects.filter(name=identifier, workspace_id=self.context["workspace_id"]).exists():
raise serializers.ValidationError(detail="Project Identifier is taken")
project = Project.objects.create(
**validated_data, workspace_id=self.context["workspace_id"]
)
project = Project.objects.create(**validated_data, workspace_id=self.context["workspace_id"])
_ = ProjectIdentifier.objects.create(
name=project.identifier,
project=project,
+1 -3
View File
@@ -14,9 +14,7 @@ class StateSerializer(BaseSerializer):
def validate(self, data):
# If the default is being provided then make all other states default False
if data.get("default", False):
State.objects.filter(project_id=self.context.get("project_id")).update(
default=False
)
State.objects.filter(project_id=self.context.get("project_id")).update(default=False)
return data
class Meta:
+13 -11
View File
@@ -1,21 +1,23 @@
from .asset import urlpatterns as asset_patterns
from .cycle import urlpatterns as cycle_patterns
from .intake import urlpatterns as intake_patterns
from .label import urlpatterns as label_patterns
from .member import urlpatterns as member_patterns
from .module import urlpatterns as module_patterns
from .project import urlpatterns as project_patterns
from .state import urlpatterns as state_patterns
from .issue import urlpatterns as issue_patterns
from .cycle import urlpatterns as cycle_patterns
from .module import urlpatterns as module_patterns
from .intake import urlpatterns as intake_patterns
from .member import urlpatterns as member_patterns
from .asset import urlpatterns as asset_patterns
from .user import urlpatterns as user_patterns
from .work_item import urlpatterns as work_item_patterns
urlpatterns = [
*asset_patterns,
*cycle_patterns,
*intake_patterns,
*label_patterns,
*member_patterns,
*module_patterns,
*project_patterns,
*state_patterns,
*issue_patterns,
*cycle_patterns,
*module_patterns,
*intake_patterns,
*member_patterns,
*user_patterns,
*work_item_patterns,
]
+1 -3
View File
@@ -14,9 +14,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-issues/<uuid:issue_id>/",
IntakeIssueDetailAPIEndpoint.as_view(
http_method_names=["get", "patch", "delete"]
),
IntakeIssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="intake-issue",
),
]
-97
View File
@@ -1,97 +0,0 @@
from django.urls import path
from plane.api.views import (
IssueListCreateAPIEndpoint,
IssueDetailAPIEndpoint,
LabelListCreateAPIEndpoint,
LabelDetailAPIEndpoint,
IssueLinkListCreateAPIEndpoint,
IssueLinkDetailAPIEndpoint,
IssueCommentListCreateAPIEndpoint,
IssueCommentDetailAPIEndpoint,
IssueActivityListAPIEndpoint,
IssueActivityDetailAPIEndpoint,
IssueAttachmentListCreateAPIEndpoint,
IssueAttachmentDetailAPIEndpoint,
WorkspaceIssueAPIEndpoint,
IssueSearchEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/issues/search/",
IssueSearchEndpoint.as_view(http_method_names=["get"]),
name="issue-search",
),
path(
"workspaces/<str:slug>/issues/<str:project_identifier>-<str:issue_identifier>/",
WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]),
name="issue-by-identifier",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/",
LabelListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="label",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/<uuid:pk>/",
LabelDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="label",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/",
IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="link",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/<uuid:pk>/",
IssueLinkDetailAPIEndpoint.as_view(
http_method_names=["get", "patch", "delete"]
),
name="link",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="comment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
IssueCommentDetailAPIEndpoint.as_view(
http_method_names=["get", "patch", "delete"]
),
name="comment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/",
IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]),
name="activity",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/<uuid:pk>/",
IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]),
name="activity",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="attachment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
IssueAttachmentDetailAPIEndpoint.as_view(
http_method_names=["get", "patch", "delete"]
),
name="issue-attachment",
),
]
+17
View File
@@ -0,0 +1,17 @@
from django.urls import path
from plane.api.views import LabelListCreateAPIEndpoint, LabelDetailAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/",
LabelListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="label",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/<uuid:pk>/",
LabelDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="label",
),
]
+1 -3
View File
@@ -19,9 +19,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archive/",
ProjectArchiveUnarchiveAPIEndpoint.as_view(
http_method_names=["post", "delete"]
),
ProjectArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]),
name="project-archive-unarchive",
),
]
+146
View File
@@ -0,0 +1,146 @@
from django.urls import path
from plane.api.views import (
IssueListCreateAPIEndpoint,
IssueDetailAPIEndpoint,
IssueLinkListCreateAPIEndpoint,
IssueLinkDetailAPIEndpoint,
IssueCommentListCreateAPIEndpoint,
IssueCommentDetailAPIEndpoint,
IssueActivityListAPIEndpoint,
IssueActivityDetailAPIEndpoint,
IssueAttachmentListCreateAPIEndpoint,
IssueAttachmentDetailAPIEndpoint,
WorkspaceIssueAPIEndpoint,
IssueSearchEndpoint,
)
# Deprecated url patterns
old_url_patterns = [
path(
"workspaces/<str:slug>/issues/search/",
IssueSearchEndpoint.as_view(http_method_names=["get"]),
name="issue-search",
),
path(
"workspaces/<str:slug>/issues/<str:project_identifier>-<str:issue_identifier>/",
WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]),
name="issue-by-identifier",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/",
IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="link",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/<uuid:pk>/",
IssueLinkDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="link",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="comment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
IssueCommentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="comment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/",
IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]),
name="activity",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/<uuid:pk>/",
IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]),
name="activity",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="attachment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="issue-attachment",
),
]
# New url patterns with work-items as the prefix
new_url_patterns = [
path(
"workspaces/<str:slug>/work-items/search/",
IssueSearchEndpoint.as_view(http_method_names=["get"]),
name="work-item-search",
),
path(
"workspaces/<str:slug>/work-items/<str:project_identifier>-<str:issue_identifier>/",
WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]),
name="work-item-by-identifier",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/",
IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="work-item-list",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:pk>/",
IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="work-item-detail",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/links/",
IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="work-item-link-list",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/links/<uuid:pk>/",
IssueLinkDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="work-item-link-detail",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/comments/",
IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="work-item-comment-list",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/comments/<uuid:pk>/",
IssueCommentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="work-item-comment-detail",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/activities/",
IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]),
name="work-item-activity-list",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/activities/<uuid:pk>/",
IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]),
name="work-item-activity-detail",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/attachments/",
IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="work-item-attachment-list",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/attachments/<uuid:pk>/",
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="work-item-attachment-detail",
),
]
urlpatterns = old_url_patterns + new_url_patterns
+18 -36
View File
@@ -8,7 +8,7 @@ from django.conf import settings
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from drf_spectacular.utils import OpenApiExample, OpenApiRequest, OpenApiTypes
from drf_spectacular.utils import OpenApiExample, OpenApiRequest
# Module Imports
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
@@ -158,9 +158,7 @@ class UserAssetEndpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
# Return the presigned URL
return Response(
{
@@ -236,9 +234,7 @@ class UserAssetEndpoint(BaseAPIView):
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
self.entity_asset_delete(
entity_type=asset.entity_type, asset=asset, request=request
)
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -282,8 +278,9 @@ class UserServerAssetEndpoint(BaseAPIView):
def post(self, request):
"""Generate presigned URL for user server asset upload.
Create a presigned URL for uploading user profile assets (avatar or cover image) using server credentials.
This endpoint generates the necessary credentials for direct S3 upload with server-side authentication.
Create a presigned URL for uploading user profile assets
(avatar or cover image) using server credentials. This endpoint generates the
necessary credentials for direct S3 upload with server-side authentication.
"""
# get the asset key
name = request.data.get("name")
@@ -334,9 +331,7 @@ class UserServerAssetEndpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request, is_server=True)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
# Return the presigned URL
return Response(
{
@@ -388,16 +383,15 @@ class UserServerAssetEndpoint(BaseAPIView):
def delete(self, request, asset_id):
"""Delete user server asset.
Delete a user profile asset (avatar or cover image) using server credentials and remove its reference from the user profile.
This performs a soft delete by marking the asset as deleted and updating the user's profile.
Delete a user profile asset (avatar or cover image) using server credentials and
remove its reference from the user profile. This performs a soft delete by marking the
asset as deleted and updating the user's profile.
"""
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
self.entity_asset_delete(
entity_type=asset.entity_type, asset=asset, request=request
)
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)
@@ -429,9 +423,7 @@ class GenericAssetEndpoint(BaseAPIView):
workspace = Workspace.objects.get(slug=slug)
# Get the asset
asset = FileAsset.objects.get(
id=asset_id, workspace_id=workspace.id, is_deleted=False
)
asset = FileAsset.objects.get(id=asset_id, workspace_id=workspace.id, is_deleted=False)
# Check if the asset exists and is uploaded
if not asset.is_uploaded:
@@ -457,13 +449,9 @@ class GenericAssetEndpoint(BaseAPIView):
)
except Workspace.DoesNotExist:
return Response(
{"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND)
except FileAsset.DoesNotExist:
return Response(
{"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
log_exception(e)
return Response(
@@ -565,14 +553,12 @@ class GenericAssetEndpoint(BaseAPIView):
created_by=request.user,
external_id=external_id,
external_source=external_source,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, # Using ISSUE_ATTACHMENT since we'll bind it to issues
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, # Using ISSUE_ATTACHMENT since we'll bind it to issues # noqa: E501
)
# Get the presigned URL
storage = S3Storage(request=request, is_server=True)
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
return Response(
{
@@ -611,9 +597,7 @@ class GenericAssetEndpoint(BaseAPIView):
and trigger metadata extraction.
"""
try:
asset = FileAsset.objects.get(
id=asset_id, workspace__slug=slug, is_deleted=False
)
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug, is_deleted=False)
# Update is_uploaded status
asset.is_uploaded = request.data.get("is_uploaded", asset.is_uploaded)
@@ -626,6 +610,4 @@ class GenericAssetEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
except FileAsset.DoesNotExist:
return Response(
{"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
+5 -15
View File
@@ -37,9 +37,7 @@ class TimezoneMixin:
timezone.deactivate()
class BaseAPIView(
TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePaginator
):
class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePaginator):
authentication_classes = [APIKeyAuthentication]
permission_classes = [IsAuthenticated]
@@ -56,9 +54,7 @@ class BaseAPIView(
api_key = self.request.headers.get("X-Api-Key")
if api_key:
service_token = APIToken.objects.filter(
token=api_key, is_service=True
).first()
service_token = APIToken.objects.filter(token=api_key, is_service=True).first()
if service_token:
throttle_classes.append(ServiceTokenRateThrottle())
@@ -113,9 +109,7 @@ class BaseAPIView(
if settings.DEBUG:
from django.db import connection
print(
f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}"
)
print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}")
return response
except Exception as exc:
response = self.handle_exception(exc)
@@ -151,14 +145,10 @@ class BaseAPIView(
@property
def fields(self):
fields = [
field for field in self.request.GET.get("fields", "").split(",") if field
]
fields = [field for field in self.request.GET.get("fields", "").split(",") if field]
return fields if fields else None
@property
def expand(self):
expand = [
expand for expand in self.request.GET.get("expand", "").split(",") if expand
]
expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand]
return expand if expand else None
+53 -116
View File
@@ -171,7 +171,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
@cycle_docs(
operation_id="list_cycles",
summary="List cycles",
description="Retrieve all cycles in a project. Supports filtering by cycle status like current, upcoming, completed, or draft.",
description="Retrieve all cycles in a project. Supports filtering by cycle status like current, upcoming, completed, or draft.", # noqa: E501
parameters=[
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
@@ -201,9 +201,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
# Current Cycle
if cycle_view == "current":
queryset = queryset.filter(
start_date__lte=timezone.now(), end_date__gte=timezone.now()
)
queryset = queryset.filter(start_date__lte=timezone.now(), end_date__gte=timezone.now())
data = CycleSerializer(
queryset,
many=True,
@@ -260,9 +258,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
# Incomplete Cycles
if cycle_view == "incomplete":
queryset = queryset.filter(
Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True)
)
queryset = queryset.filter(Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True))
return self.paginate(
request=request,
queryset=(queryset),
@@ -289,7 +285,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
@cycle_docs(
operation_id="create_cycle",
summary="Create cycle",
description="Create a new development cycle with specified name, description, and date range. Supports external ID tracking for integration purposes.",
description="Create a new development cycle with specified name, description, and date range. Supports external ID tracking for integration purposes.", # noqa: E501
request=OpenApiRequest(
request=CycleCreateSerializer,
examples=[CYCLE_CREATE_EXAMPLE],
@@ -308,14 +304,11 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
Create a new development cycle with specified name, description, and date range.
Supports external ID tracking for integration purposes.
"""
if (
request.data.get("start_date", None) is None
and request.data.get("end_date", None) is None
) or (
request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
):
serializer = CycleCreateSerializer(data=request.data)
serializer = CycleCreateSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -340,7 +333,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
},
status=status.HTTP_409_CONFLICT,
)
serializer.save(project_id=project_id, owned_by=request.user)
serializer.save(project_id=project_id)
# Send the model activity
model_activity.delay(
model_name="cycle",
@@ -358,9 +351,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
{
"error": "Both start date and end date are either required or are to be null"
},
{"error": "Both start date and end date are either required or are to be null"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -487,7 +478,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
@cycle_docs(
operation_id="update_cycle",
summary="Update cycle",
description="Modify an existing cycle's properties like name, description, or date range. Completed cycles can only have their sort order changed.",
description="Modify an existing cycle's properties like name, description, or date range. Completed cycles can only have their sort order changed.", # noqa: E501
request=OpenApiRequest(
request=CycleUpdateSerializer,
examples=[CYCLE_UPDATE_EXAMPLE],
@@ -508,9 +499,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
"""
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
current_instance = json.dumps(
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
)
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
if cycle.archived_at:
return Response(
@@ -523,18 +512,14 @@ class CycleDetailAPIEndpoint(BaseAPIView):
if cycle.end_date is not None and cycle.end_date < timezone.now():
if "sort_order" in request_data:
# Can only change sort order
request_data = {
"sort_order": request_data.get("sort_order", cycle.sort_order)
}
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
else:
return Response(
{
"error": "The Cycle has already been completed so it cannot be edited"
},
{"error": "The Cycle has already been completed so it cannot be edited"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True)
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True, context={"request": request})
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -542,9 +527,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
and Cycle.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get(
"external_source", cycle.external_source
),
external_source=request.data.get("external_source", cycle.external_source),
external_id=request.data.get("external_id"),
).exists()
):
@@ -601,11 +584,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_403_FORBIDDEN,
)
cycle_issues = list(
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
"issue", flat=True
)
)
cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
issue_activity.delay(
type="cycle.activity.deleted",
@@ -625,9 +604,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
# Delete the cycle
cycle.delete()
# Delete the user favorite cycle
UserFavorite.objects.filter(
entity_type="cycle", entity_identifier=pk, project_id=project_id
).delete()
UserFavorite.objects.filter(entity_type="cycle", entity_identifier=pk, project_id=project_id).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -765,15 +742,13 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda cycles: CycleSerializer(cycles, many=True, fields=self.fields, expand=self.expand).data,
)
@cycle_docs(
operation_id="archive_cycle",
summary="Archive cycle",
description="Move a completed cycle to archived status for historical tracking. Only cycles that have ended can be archived.",
description="Move a completed cycle to archived status for historical tracking. Only cycles that have ended can be archived.", # noqa: E501
request={},
responses={
204: ARCHIVED_RESPONSE,
@@ -786,9 +761,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
Move a completed cycle to archived status for historical tracking.
Only cycles that have ended can be archived.
"""
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
if cycle.end_date >= timezone.now():
return Response(
{"error": "Only completed cycles can be archived"},
@@ -819,9 +792,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
Restore an archived cycle to active status, making it available for regular use.
The cycle will reappear in active cycle lists.
"""
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
cycle.archived_at = None
cycle.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -884,9 +855,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
# List
order_by = request.GET.get("order_by", "created_at")
issues = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
)
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
@@ -923,15 +892,13 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(issues),
on_results=lambda issues: IssueSerializer(
issues, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data,
)
@cycle_docs(
operation_id="add_cycle_work_items",
summary="Add Work Items to Cycle",
description="Assign multiple work items to a cycle. Automatically handles bulk creation and updates with activity tracking.",
description="Assign multiple work items to a cycle. Automatically handles bulk creation and updates with activity tracking.", # noqa: E501
request=OpenApiRequest(
request=CycleIssueRequestSerializer,
examples=[CYCLE_ISSUE_REQUEST_EXAMPLE],
@@ -955,22 +922,24 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
if not issues:
return Response(
{"error": "Work items are required"}, status=status.HTTP_400_BAD_REQUEST
{"error": "Work items are required", "code": "MISSING_WORK_ITEMS"}, 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(
{
"code": "CYCLE_COMPLETED",
"message": "The Cycle has already been completed so no new issues can be added",
},
status=status.HTTP_400_BAD_REQUEST,
)
# 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))
@@ -1021,9 +990,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize(
"json", created_records
),
"created_cycle_issues": serializers.serialize("json", created_records),
}
),
epoch=int(timezone.now().timestamp()),
@@ -1099,9 +1066,7 @@ class CycleIssueDetailAPIEndpoint(BaseAPIView):
cycle_id=cycle_id,
issue_id=issue_id,
)
serializer = CycleIssueSerializer(
cycle_issue, fields=self.fields, expand=self.expand
)
serializer = CycleIssueSerializer(cycle_issue, fields=self.fields, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
@cycle_docs(
@@ -1154,7 +1119,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
@cycle_docs(
operation_id="transfer_cycle_work_items",
summary="Transfer cycle work items",
description="Move incomplete work items from the current cycle to a new target cycle. Captures progress snapshot and transfers only unfinished work items.",
description="Move incomplete work items from the current cycle to a new target cycle. Captures progress snapshot and transfers only unfinished work items.", # noqa: E501
request=OpenApiRequest(
request=TransferCycleIssueRequestSerializer,
examples=[TRANSFER_CYCLE_ISSUE_EXAMPLE],
@@ -1207,14 +1172,10 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
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()
old_cycle = (
Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
.annotate(
total_issues=Count(
"issue_cycle",
@@ -1324,9 +1285,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
)
)
.values("display_name", "assignee_id", "avatar", "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()),
@@ -1353,9 +1312,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
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": item.get("avatar", None),
"avatar_url": item.get("avatar_url", None),
"total_estimates": item["total_estimates"],
@@ -1376,9 +1333,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
)
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
@@ -1445,19 +1400,13 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True, then="assignees__avatar"
),
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
default=Value(None),
output_field=models.CharField(),
)
)
.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",
@@ -1484,9 +1433,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
assignee_distribution_data = [
{
"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": item.get("avatar", None),
"avatar_url": item.get("avatar_url", None),
"total_issues": item["total_issues"],
@@ -1508,11 +1455,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
.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",
@@ -1558,9 +1501,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
cycle_id=cycle_id,
)
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,
@@ -1588,9 +1529,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
return Response(
{
"error": "The cycle where the issues are transferred is already completed"
},
{"error": "The cycle where the issues are transferred is already completed"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -1614,9 +1553,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
}
)
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(
+30 -79
View File
@@ -62,11 +62,9 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
project_id=self.kwargs.get("project_id"),
).first()
project = Project.objects.get(
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
)
project = Project.objects.get(workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id"))
if intake is None and not project.intake_view:
if intake is None or not project.intake_view:
return IntakeIssue.objects.none()
return (
@@ -83,7 +81,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
@intake_docs(
operation_id="get_intake_work_items_list",
summary="List intake work items",
description="Retrieve all work items in the project's intake queue. Returns paginated results when listing all intake work items.",
description="Retrieve all work items in the project's intake queue. Returns paginated results when listing all intake work items.", # noqa: E501
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
@@ -119,7 +117,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
@intake_docs(
operation_id="create_intake_work_item",
summary="Create intake work item",
description="Submit a new work item to the project's intake queue for review and triage. Automatically creates the work item with default triage state and tracks activity.",
description="Submit a new work item to the project's intake queue for review and triage. Automatically creates the work item with default triage state and tracks activity.", # noqa: E501
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
@@ -144,22 +142,16 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
Automatically creates the work item with default triage state and tracks activity.
"""
if not request.data.get("issue", {}).get("name", False):
return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST)
intake = Intake.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first()
project = Project.objects.get(workspace__slug=slug, pk=project_id)
# Intake view
if intake is None and not project.intake_view:
return Response(
{
"error": "Intake is not enabled for this project enable it through the project's api"
},
{"error": "Intake is not enabled for this project enable it through the project's api"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -171,17 +163,13 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
"urgent",
"none",
]:
return Response(
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
# create an issue
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", "<p></p>"
),
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
priority=request.data.get("issue", {}).get("priority", "none"),
project_id=project_id,
)
@@ -226,11 +214,9 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
project_id=self.kwargs.get("project_id"),
).first()
project = Project.objects.get(
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
)
project = Project.objects.get(workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id"))
if intake is None and not project.intake_view:
if intake is None or not project.intake_view:
return IntakeIssue.objects.none()
return (
@@ -267,15 +253,13 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
Retrieve details of a specific intake work item.
"""
intake_issue_queryset = self.get_queryset().get(issue_id=issue_id)
intake_issue_data = IntakeIssueSerializer(
intake_issue_queryset, fields=self.fields, expand=self.expand
).data
intake_issue_data = IntakeIssueSerializer(intake_issue_queryset, fields=self.fields, expand=self.expand).data
return Response(intake_issue_data, status=status.HTTP_200_OK)
@intake_docs(
operation_id="update_intake_work_item",
summary="Update intake work item",
description="Modify an existing intake work item's properties or status for triage processing. Supports status changes like accept, reject, or mark as duplicate.",
description="Modify an existing intake work item's properties or status for triage processing. Supports status changes like accept, reject, or mark as duplicate.", # noqa: E501
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
@@ -300,18 +284,14 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
Modify an existing intake work item's properties or status for triage processing.
Supports status changes like accept, reject, or mark as duplicate.
"""
intake = Intake.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first()
project = Project.objects.get(workspace__slug=slug, pk=project_id)
# Intake view
if intake is None and not project.intake_view:
return Response(
{
"error": "Intake is not enabled for this project enable it through the project's api"
},
{"error": "Intake is not enabled for this project enable it through the project's api"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -332,9 +312,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
)
# Only project members admins and created_by users can access this endpoint
if project_member.role <= 5 and str(intake_issue.created_by_id) != str(
request.user.id
):
if project_member.role <= 5 and str(intake_issue.created_by_id) != str(request.user.id):
return Response(
{"error": "You cannot edit intake work items"},
status=status.HTTP_400_BAD_REQUEST,
@@ -349,10 +327,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -373,9 +348,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
if project_member.role <= 5:
issue_data = {
"name": issue_data.get("name", issue.name),
"description_html": issue_data.get(
"description_html", issue.description_html
),
"description_html": issue_data.get("description_html", issue.description_html),
"description": issue_data.get("description", issue.description),
}
@@ -401,45 +374,31 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
)
issue_serializer.save()
else:
return Response(
issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Only project admins and members can edit intake issue attributes
if project_member.role > 15:
serializer = IntakeIssueUpdateSerializer(
intake_issue, data=request.data, partial=True
)
current_instance = json.dumps(
IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder
)
serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
if serializer.is_valid():
serializer.save()
# Update the issue state if the issue is rejected or marked as duplicate
if serializer.data["status"] in [-1, 2]:
issue = Issue.objects.get(
pk=issue_id, workspace__slug=slug, project_id=project_id
)
state = State.objects.filter(
group="cancelled", workspace__slug=slug, project_id=project_id
).first()
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first()
if state is not None:
issue.state = state
issue.save()
# Update the issue state if it is accepted
if serializer.data["status"] in [1]:
issue = Issue.objects.get(
pk=issue_id, workspace__slug=slug, project_id=project_id
)
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
# Update the issue state only if it is in triage state
if issue.state.is_triage:
# Move to default state
state = State.objects.filter(
workspace__slug=slug, project_id=project_id, default=True
).first()
state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first()
if state is not None:
issue.state = state
issue.save()
@@ -461,14 +420,12 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK
)
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
@intake_docs(
operation_id="delete_intake_work_item",
summary="Delete intake work item",
description="Permanently remove an intake work item from the triage queue. Also deletes the underlying work item if it hasn't been accepted yet.",
description="Permanently remove an intake work item from the triage queue. Also deletes the underlying work item if it hasn't been accepted yet.", # noqa: E501
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
@@ -484,18 +441,14 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
Permanently remove an intake work item from the triage queue.
Also deletes the underlying work item if it hasn't been accepted yet.
"""
intake = Intake.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first()
project = Project.objects.get(workspace__slug=slug, pk=project_id)
# Intake view
if intake is None and not project.intake_view:
return Response(
{
"error": "Intake is not enabled for this project enable it through the project's api"
},
{"error": "Intake is not enabled for this project enable it through the project's api"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -510,9 +463,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
# Check the issue status
if intake_issue.status in [-2, -1, 0, 2]:
# Delete the issue also
issue = Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk=issue_id
).first()
issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=issue_id).first()
if issue.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
+55 -160
View File
@@ -30,12 +30,10 @@ from rest_framework.response import Response
# drf-spectacular imports
from drf_spectacular.utils import (
extend_schema,
OpenApiParameter,
OpenApiResponse,
OpenApiExample,
OpenApiRequest,
)
from drf_spectacular.types import OpenApiTypes
# Module imports
from plane.api.serializers import (
@@ -99,7 +97,6 @@ from plane.utils.openapi import (
EXTERNAL_ID_PARAMETER,
EXTERNAL_SOURCE_PARAMETER,
ORDER_BY_PARAMETER,
SEARCH_PARAMETER,
SEARCH_PARAMETER_REQUIRED,
LIMIT_PARAMETER,
WORKSPACE_SEARCH_PARAMETER,
@@ -145,9 +142,8 @@ from plane.utils.openapi import (
)
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
def user_has_issue_permission(
user_id, project_id, issue=None, allowed_roles=None, allow_creator=True
):
def user_has_issue_permission(user_id, project_id, issue=None, allowed_roles=None, allow_creator=True):
if allow_creator and issue is not None and user_id == issue.created_by_id:
return True
@@ -272,7 +268,7 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
@work_item_docs(
operation_id="list_work_items",
summary="List work items",
description="Retrieve a paginated list of all work items in a project. Supports filtering, ordering, and field selection through query parameters.",
description="Retrieve a paginated list of all work items in a project. Supports filtering, ordering, and field selection through query parameters.", # noqa: E501
parameters=[
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
@@ -325,9 +321,7 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
self.get_queryset()
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
).values("cycle_id")[:1]
CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1]
)
)
.annotate(
@@ -347,21 +341,14 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
)
)
total_issue_queryset = Issue.issue_objects.filter(
project_id=project_id, workspace__slug=slug
)
total_issue_queryset = Issue.issue_objects.filter(project_id=project_id, workspace__slug=slug)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order if order_by_param == "priority" else priority_order[::-1]
)
priority_order = priority_order if order_by_param == "priority" else priority_order[::-1]
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
*[When(priority=p, then=Value(i)) for i, p in enumerate(priority_order)],
output_field=CharField(),
)
).order_by("priority_order")
@@ -373,17 +360,10 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
state_order = state_order if order_by_param in ["state__name", "state__group"] else state_order[::-1]
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
*[When(state__group=state_group, then=Value(i)) for i, state_group in enumerate(state_order)],
default=Value(len(state_order)),
output_field=CharField(),
)
@@ -396,14 +376,8 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
)
max_values=Max(order_by_param[1::] if order_by_param.startswith("-") else order_by_param)
).order_by("-max_values" if order_by_param.startswith("-") else "max_values")
else:
issue_queryset = issue_queryset.order_by(order_by_param)
@@ -411,9 +385,7 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
request=request,
queryset=(issue_queryset),
total_count_queryset=total_issue_queryset,
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,
)
@work_item_docs(
@@ -479,9 +451,7 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
serializer.save()
# Refetch the issue
issue = Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk=serializer.data["id"]
).first()
issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=serializer.data["id"]).first()
issue.created_at = request.data.get("created_at", timezone.now())
issue.created_by_id = request.data.get("created_by", request.user.id)
issue.save(update_fields=["created_at", "created_by"])
@@ -582,7 +552,7 @@ class IssueDetailAPIEndpoint(BaseAPIView):
@work_item_docs(
operation_id="put_work_item",
summary="Update or create work item",
description="Update an existing work item identified by external ID and source, or create a new one if it doesn't exist. Requires external_id and external_source parameters for identification.",
description="Update an existing work item identified by external ID and source, or create a new one if it doesn't exist. Requires external_id and external_source parameters for identification.", # noqa: E501
request=OpenApiRequest(
request=IssueSerializer,
examples=[ISSUE_UPSERT_EXAMPLE],
@@ -628,9 +598,7 @@ class IssueDetailAPIEndpoint(BaseAPIView):
# Get the current instance of the issue in order to track
# changes and dispatch the issue activity
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
current_instance = json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder)
# Get the requested data, encode it as django object and pass it
# to serializer to validation
@@ -693,16 +661,12 @@ class IssueDetailAPIEndpoint(BaseAPIView):
# the issue with the provided data, else return with the
# default states given.
issue.created_at = request.data.get("created_at", timezone.now())
issue.created_by_id = request.data.get(
"created_by", request.user.id
)
issue.created_by_id = request.data.get("created_by", request.user.id)
issue.save(update_fields=["created_at", "created_by"])
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(
self.request.data, cls=DjangoJSONEncoder
),
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
@@ -720,7 +684,7 @@ class IssueDetailAPIEndpoint(BaseAPIView):
@work_item_docs(
operation_id="update_work_item",
summary="Partially update work item",
description="Partially update an existing work item with the provided fields. Supports external ID validation to prevent conflicts.",
description="Partially update an existing work item with the provided fields. Supports external ID validation to prevent conflicts.", # noqa: E501
parameters=[
PROJECT_ID_PARAMETER,
],
@@ -747,9 +711,7 @@ class IssueDetailAPIEndpoint(BaseAPIView):
"""
issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
project = Project.objects.get(pk=project_id)
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
current_instance = json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
serializer = IssueSerializer(
issue,
@@ -764,9 +726,7 @@ class IssueDetailAPIEndpoint(BaseAPIView):
and Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get(
"external_source", issue.external_source
),
external_source=request.data.get("external_source", issue.external_source),
external_id=request.data.get("external_id"),
).exists()
):
@@ -794,7 +754,7 @@ class IssueDetailAPIEndpoint(BaseAPIView):
@work_item_docs(
operation_id="delete_work_item",
summary="Delete work item",
description="Permanently delete an existing work item from the project. Only admins or the item creator can perform this action.",
description="Permanently delete an existing work item from the project. Only admins or the item creator can perform this action.", # noqa: E501
parameters=[
PROJECT_ID_PARAMETER,
],
@@ -824,9 +784,7 @@ class IssueDetailAPIEndpoint(BaseAPIView):
{"error": "Only admin or creator can delete the work item"},
status=status.HTTP_403_FORBIDDEN,
)
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
current_instance = json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder)
issue.delete()
issue_activity.delay(
type="issue.activity.deleted",
@@ -962,9 +920,7 @@ class LabelListCreateAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda labels: LabelSerializer(
labels, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda labels: LabelSerializer(labels, many=True, fields=self.fields, expand=self.expand).data,
)
@@ -1036,9 +992,7 @@ class LabelDetailAPIEndpoint(LabelListCreateAPIEndpoint):
and Label.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get(
"external_source", label.external_source
),
external_source=request.data.get("external_source", label.external_source),
external_id=request.data.get("external_id"),
)
.exclude(id=pk)
@@ -1165,9 +1119,7 @@ class IssueLinkListCreateAPIEndpoint(BaseAPIView):
serializer = IssueLinkCreateSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
crawl_work_item_link_title.delay(
serializer.instance.id, serializer.instance.url
)
crawl_work_item_link_title.delay(serializer.instance.id, serializer.instance.url)
link = IssueLink.objects.get(pk=serializer.instance.id)
link.created_by_id = request.data.get("created_by", request.user.id)
link.save(update_fields=["created_by"])
@@ -1236,9 +1188,7 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView):
"""
if pk is None:
issue_links = self.get_queryset()
serializer = IssueLinkSerializer(
issue_links, fields=self.fields, expand=self.expand
)
serializer = IssueLinkSerializer(issue_links, fields=self.fields, expand=self.expand)
return self.paginate(
request=request,
queryset=(self.get_queryset()),
@@ -1247,9 +1197,7 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView):
).data,
)
issue_link = self.get_queryset().get(pk=pk)
serializer = IssueLinkSerializer(
issue_link, fields=self.fields, expand=self.expand
)
serializer = IssueLinkSerializer(issue_link, fields=self.fields, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
@issue_link_docs(
@@ -1279,19 +1227,13 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView):
Modify the URL, title, or metadata of an existing issue link.
Tracks all changes in issue activity logs.
"""
issue_link = IssueLink.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
)
issue_link = IssueLink.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk)
requested_data = json.dumps(request.data, cls=DjangoJSONEncoder)
current_instance = json.dumps(
IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder
)
current_instance = json.dumps(IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder)
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
crawl_work_item_link_title.delay(serializer.data.get("id"), serializer.data.get("url"))
issue_activity.delay(
type="link.activity.updated",
requested_data=requested_data,
@@ -1323,12 +1265,8 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView):
Permanently remove an external link from a work item.
Records deletion activity for audit purposes.
"""
issue_link = IssueLink.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
)
current_instance = json.dumps(
IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder
)
issue_link = IssueLink.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk)
current_instance = json.dumps(IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder)
issue_activity.delay(
type="link.activity.deleted",
requested_data=json.dumps({"link_id": str(pk)}),
@@ -1464,15 +1402,12 @@ class IssueCommentListCreateAPIEndpoint(BaseAPIView):
serializer = IssueCommentCreateSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id, issue_id=issue_id, actor=request.user
)
serializer.save(project_id=project_id, issue_id=issue_id, actor=request.user)
issue_comment = IssueComment.objects.get(pk=serializer.instance.id)
# Update the created_at and the created_by and save the comment
issue_comment.created_at = request.data.get("created_at", timezone.now())
issue_comment.created_by_id = request.data.get(
"created_by", request.user.id
)
issue_comment.created_by_id = request.data.get("created_by", request.user.id)
issue_comment.actor_id = request.data.get("created_by", request.user.id)
issue_comment.save(update_fields=["created_at", "created_by"])
issue_activity.delay(
@@ -1558,9 +1493,7 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView):
Retrieve details of a specific comment.
"""
issue_comment = self.get_queryset().get(pk=pk)
serializer = IssueCommentSerializer(
issue_comment, fields=self.fields, expand=self.expand
)
serializer = IssueCommentSerializer(issue_comment, fields=self.fields, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
@issue_comment_docs(
@@ -1591,13 +1524,9 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView):
Modify the content of an existing comment on a work item.
Validates external ID uniqueness if provided.
"""
issue_comment = IssueComment.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
)
issue_comment = IssueComment.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
current_instance = json.dumps(
IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder
)
current_instance = json.dumps(IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder)
# Validation check if the issue already exists
if (
@@ -1606,9 +1535,7 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView):
and IssueComment.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get(
"external_source", issue_comment.external_source
),
external_source=request.data.get("external_source", issue_comment.external_source),
external_id=request.data.get("external_id"),
).exists()
):
@@ -1620,9 +1547,7 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
serializer = IssueCommentCreateSerializer(
issue_comment, data=request.data, partial=True
)
serializer = IssueCommentCreateSerializer(issue_comment, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
@@ -1668,12 +1593,8 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView):
Permanently remove a comment from a work item.
Records deletion activity for audit purposes.
"""
issue_comment = IssueComment.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
)
current_instance = json.dumps(
IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder
)
issue_comment = IssueComment.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk)
current_instance = json.dumps(IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder)
issue_comment.delete()
issue_activity.delay(
type="comment.activity.deleted",
@@ -1720,9 +1641,7 @@ class IssueActivityListAPIEndpoint(BaseAPIView):
Excludes comment, vote, reaction, and draft activities.
"""
issue_activities = (
IssueActivity.objects.filter(
issue_id=issue_id, workspace__slug=slug, project_id=project_id
)
IssueActivity.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id)
.filter(
~Q(field__in=["comment", "vote", "reaction", "draft"]),
project__project_projectmember__member=self.request.user,
@@ -1777,9 +1696,7 @@ class IssueActivityDetailAPIEndpoint(BaseAPIView):
Excludes comment, vote, reaction, and draft activities.
"""
issue_activities = (
IssueActivity.objects.filter(
issue_id=issue_id, workspace__slug=slug, project_id=project_id
)
IssueActivity.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id)
.filter(
~Q(field__in=["comment", "vote", "reaction", "draft"]),
project__project_projectmember__member=self.request.user,
@@ -1869,12 +1786,8 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
name="Workspace not found",
value={"error": "Workspace not found"},
),
OpenApiExample(
name="Project not found", value={"error": "Project not found"}
),
OpenApiExample(
name="Issue not found", value={"error": "Issue not found"}
),
OpenApiExample(name="Project not found", value={"error": "Project not found"}),
OpenApiExample(name="Issue not found", value={"error": "Issue not found"}),
],
),
},
@@ -1885,9 +1798,7 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
Generate presigned URL for uploading file attachments to a work item.
Validates file type and size before creating the attachment record.
"""
issue = Issue.objects.get(
pk=issue_id, workspace__slug=slug, project_id=project_id
)
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
# if the user is creator or admin,member then allow the upload
if not user_has_issue_permission(
request.user.id,
@@ -1973,9 +1884,7 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
# Return the presigned URL
return Response(
{
@@ -2035,9 +1944,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
ATTACHMENT_ID_PARAMETER,
],
responses={
204: OpenApiResponse(
description="Work item attachment deleted successfully"
),
204: OpenApiResponse(description="Work item attachment deleted successfully"),
404: ATTACHMENT_NOT_FOUND_RESPONSE,
},
)
@@ -2047,9 +1954,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
Soft delete an attachment from a work item by marking it as deleted.
Records deletion activity and triggers metadata cleanup.
"""
issue = Issue.objects.get(
pk=issue_id, workspace__slug=slug, project_id=project_id
)
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
# if the request user is creator or admin then delete the attachment
if not user_has_issue_permission(
request.user,
@@ -2063,9 +1968,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_403_FORBIDDEN,
)
issue_attachment = FileAsset.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
issue_attachment.is_deleted = True
issue_attachment.deleted_at = timezone.now()
issue_attachment.save()
@@ -2139,9 +2042,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
)
# 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 if the asset is uploaded
if not asset.is_uploaded:
@@ -2179,9 +2080,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
examples=[ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE],
),
responses={
204: OpenApiResponse(
description="Work item attachment uploaded successfully"
),
204: OpenApiResponse(description="Work item attachment uploaded successfully"),
400: INVALID_REQUEST_RESPONSE,
404: ATTACHMENT_NOT_FOUND_RESPONSE,
},
@@ -2193,9 +2092,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
Triggers activity logging and metadata extraction.
"""
issue = Issue.objects.get(
pk=issue_id, workspace__slug=slug, project_id=project_id
)
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
# if the user is creator or admin then allow the upload
if not user_has_issue_permission(
request.user,
@@ -2209,9 +2106,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_403_FORBIDDEN,
)
issue_attachment = FileAsset.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
serializer = IssueAttachmentSerializer(issue_attachment)
# Send this activity only if the attachment is not uploaded before
+5 -9
View File
@@ -74,9 +74,7 @@ class WorkspaceMemberAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug
).select_related("member")
workspace_members = WorkspaceMember.objects.filter(workspace__slug=slug).select_related("member")
# Get all the users with their roles
users_with_roles = []
@@ -125,13 +123,11 @@ class ProjectMemberAPIEndpoint(BaseAPIView):
)
# Get the workspace members that are present inside the workspace
project_members = ProjectMember.objects.filter(
project_id=project_id, workspace__slug=slug
).values_list("member_id", flat=True)
project_members = ProjectMember.objects.filter(project_id=project_id, workspace__slug=slug).values_list(
"member_id", flat=True
)
# Get all the users that are present inside the workspace
users = UserLiteSerializer(
User.objects.filter(id__in=project_members), many=True
).data
users = UserLiteSerializer(User.objects.filter(id__in=project_members), many=True).data
return Response(users, status=status.HTTP_200_OK)
+20 -54
View File
@@ -10,7 +10,7 @@ from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
# Module imports
from plane.api.serializers import (
@@ -41,8 +41,6 @@ from plane.utils.host import base_host
from plane.utils.openapi import (
module_docs,
module_issue_docs,
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
MODULE_ID_PARAMETER,
MODULE_PK_PARAMETER,
ISSUE_ID_PARAMETER,
@@ -396,9 +394,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
examples=[MODULE_UPDATE_EXAMPLE],
),
404: OpenApiResponse(description="Module not found"),
409: OpenApiResponse(
description="Module with same external ID already exists"
),
409: OpenApiResponse(description="Module with same external ID already exists"),
},
)
def patch(self, request, slug, project_id, pk):
@@ -409,18 +405,14 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
"""
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
current_instance = json.dumps(
ModuleSerializer(module).data, cls=DjangoJSONEncoder
)
current_instance = json.dumps(ModuleSerializer(module).data, cls=DjangoJSONEncoder)
if module.archived_at:
return Response(
{"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 = ModuleSerializer(module, data=request.data, context={"project_id": project_id}, partial=True)
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -428,9 +420,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
and Module.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get(
"external_source", module.external_source
),
external_source=request.data.get("external_source", module.external_source),
external_id=request.data.get("external_id"),
).exists()
):
@@ -516,9 +506,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_403_FORBIDDEN,
)
module_issues = list(
ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True)
)
module_issues = list(ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True))
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
@@ -539,9 +527,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
# Delete the module issues
ModuleIssue.objects.filter(module=pk, project_id=project_id).delete()
# Delete the user favorite module
UserFavorite.objects.filter(
entity_type="module", entity_identifier=pk, project_id=project_id
).delete()
UserFavorite.objects.filter(entity_type="module", entity_identifier=pk, project_id=project_id).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -611,9 +597,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
"""
order_by = request.GET.get("order_by", "created_at")
issues = (
Issue.issue_objects.filter(
issue_module__module_id=module_id, issue_module__deleted_at__isnull=True
)
Issue.issue_objects.filter(issue_module__module_id=module_id, issue_module__deleted_at__isnull=True)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
@@ -649,15 +633,13 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(issues),
on_results=lambda issues: IssueSerializer(
issues, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data,
)
@module_issue_docs(
operation_id="add_module_work_items",
summary="Add Work Items to Module",
description="Assign multiple work items to a module or move them from another module. Automatically handles bulk creation and updates with activity tracking.",
description="Assign multiple work items to a module or move them from another module. Automatically handles bulk creation and updates with activity tracking.", # noqa: E501
parameters=[
MODULE_ID_PARAMETER,
],
@@ -683,16 +665,12 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
"""
issues = request.data.get("issues", [])
if not len(issues):
return Response(
{"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST
)
module = Module.objects.get(
workspace__slug=slug, project_id=project_id, pk=module_id
)
return Response({"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST)
module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=module_id)
issues = Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issues
).values_list("id", flat=True)
issues = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk__in=issues).values_list(
"id", flat=True
)
module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues))
@@ -701,11 +679,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
record_to_create = []
for issue in issues:
module_issue = [
module_issue
for module_issue in module_issues
if str(module_issue.issue_id) in issues
]
module_issue = [module_issue for module_issue in module_issues if str(module_issue.issue_id) in issues]
if len(module_issue):
if module_issue[0].module_id != module_id:
@@ -730,9 +704,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
)
)
ModuleIssue.objects.bulk_create(
record_to_create, batch_size=10, ignore_conflicts=True
)
ModuleIssue.objects.bulk_create(record_to_create, batch_size=10, ignore_conflicts=True)
ModuleIssue.objects.bulk_update(records_to_update, ["module"], batch_size=10)
@@ -746,9 +718,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
current_instance=json.dumps(
{
"updated_module_issues": update_module_issue_activity,
"created_module_issues": serializers.serialize(
"json", record_to_create
),
"created_module_issues": serializers.serialize("json", record_to_create),
}
),
epoch=int(timezone.now().timestamp()),
@@ -873,9 +843,7 @@ class ModuleIssueDetailAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(issues),
on_results=lambda issues: IssueSerializer(
issues, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data,
)
@module_issue_docs(
@@ -906,9 +874,7 @@ class ModuleIssueDetailAPIEndpoint(BaseAPIView):
module_issue.delete()
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
{"module_id": str(module_id), "issues": [str(module_issue.issue_id)]}
),
requested_data=json.dumps({"module_id": str(module_id), "issues": [str(module_issue.issue_id)]}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
+17 -37
View File
@@ -11,7 +11,7 @@ from django.core.serializers.json import DjangoJSONEncoder
from rest_framework import status
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
# Module imports
@@ -79,9 +79,7 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
)
| Q(network=2)
)
.select_related(
"workspace", "workspace__owner", "default_assignee", "project_lead"
)
.select_related("project_lead")
.annotate(
is_member=Exists(
ProjectMember.objects.filter(
@@ -170,9 +168,9 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
.prefetch_related(
Prefetch(
"project_projectmember",
queryset=ProjectMember.objects.filter(
workspace__slug=slug, is_active=True
).select_related("member"),
queryset=ProjectMember.objects.filter(workspace__slug=slug, is_active=True).select_related(
"member"
),
)
)
.order_by(request.GET.get("order_by", "sort_order"))
@@ -211,24 +209,18 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
"""
try:
workspace = Workspace.objects.get(slug=slug)
serializer = ProjectCreateSerializer(
data={**request.data}, context={"workspace_id": workspace.id}
)
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
)
_ = 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
)
_ = 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):
if serializer.instance.project_lead is not None and str(serializer.instance.project_lead) != str(
request.user.id
):
ProjectMember.objects.create(
project_id=serializer.instance.id,
member_id=serializer.instance.project_lead,
@@ -314,9 +306,7 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
except Workspace.DoesNotExist:
return Response(
{"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND)
except ValidationError:
return Response(
{"identifier": "The project identifier is already taken"},
@@ -344,9 +334,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
)
| Q(network=2)
)
.select_related(
"workspace", "workspace__owner", "default_assignee", "project_lead"
)
.select_related("workspace", "workspace__owner", "default_assignee", "project_lead")
.annotate(
is_member=Exists(
ProjectMember.objects.filter(
@@ -451,9 +439,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
try:
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
current_instance = json.dumps(
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
current_instance = json.dumps(ProjectSerializer(project).data, cls=DjangoJSONEncoder)
intake_view = request.data.get("intake_view", project.intake_view)
@@ -473,9 +459,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
if serializer.is_valid():
serializer.save()
if serializer.data["intake_view"]:
intake = Intake.objects.filter(
project=project, is_default=True
).first()
intake = Intake.objects.filter(project=project, is_default=True).first()
if not intake:
Intake.objects.create(
name=f"{project.name} Intake",
@@ -505,9 +489,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
except (Project.DoesNotExist, Workspace.DoesNotExist):
return Response(
{"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND)
except ValidationError:
return Response(
{"identifier": "The project identifier is already taken"},
@@ -533,9 +515,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView):
"""
project = Project.objects.get(pk=pk, workspace__slug=slug)
# Delete the user favorite cycle
UserFavorite.objects.filter(
entity_type="project", entity_identifier=pk, project_id=pk
).delete()
UserFavorite.objects.filter(entity_type="project", entity_identifier=pk, project_id=pk).delete()
project.delete()
webhook_activity.delay(
event="project",
+7 -17
View File
@@ -4,7 +4,7 @@ from django.db import IntegrityError
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
# Module imports
from plane.api.serializers import StateSerializer
@@ -80,9 +80,7 @@ class StateListCreateAPIEndpoint(BaseAPIView):
Supports external ID tracking for integration purposes.
"""
try:
serializer = StateSerializer(
data=request.data, context={"project_id": project_id}
)
serializer = StateSerializer(data=request.data, context={"project_id": project_id})
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -153,9 +151,7 @@ class StateListCreateAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda states: StateSerializer(
states, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda states: StateSerializer(states, many=True, fields=self.fields, expand=self.expand).data,
)
@@ -213,7 +209,7 @@ class StateDetailAPIEndpoint(BaseAPIView):
@state_docs(
operation_id="delete_state",
summary="Delete state",
description="Permanently remove a workflow state from a project. Default states and states with existing work items cannot be deleted.",
description="Permanently remove a workflow state from a project. Default states and states with existing work items cannot be deleted.", # noqa: E501
parameters=[
STATE_ID_PARAMETER,
],
@@ -228,9 +224,7 @@ class StateDetailAPIEndpoint(BaseAPIView):
Permanently remove a workflow state from a project.
Default states and states with existing work items cannot be deleted.
"""
state = State.objects.get(
is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug
)
state = State.objects.get(is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug)
if state.default:
return Response(
@@ -277,9 +271,7 @@ class StateDetailAPIEndpoint(BaseAPIView):
Partially update an existing workflow state's properties like name, color, or group.
Validates external ID uniqueness if provided.
"""
state = State.objects.get(
workspace__slug=slug, project_id=project_id, pk=state_id
)
state = State.objects.get(workspace__slug=slug, project_id=project_id, pk=state_id)
serializer = StateSerializer(state, data=request.data, partial=True)
if serializer.is_valid():
if (
@@ -288,9 +280,7 @@ class StateDetailAPIEndpoint(BaseAPIView):
and State.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get(
"external_source", state.external_source
),
external_source=request.data.get("external_source", state.external_source),
external_id=request.data.get("external_id"),
).exists()
):
@@ -13,3 +13,4 @@ from .project import (
ProjectLitePermission,
)
from .base import allow_permission, ROLE
from .page import ProjectPagePermission
+22 -8
View File
@@ -18,16 +18,12 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
def _wrapped_view(instance, request, *args, **kwargs):
# Check for creator if required
if creator and model:
obj = model.objects.filter(
id=kwargs["pk"], created_by=request.user
).exists()
obj = model.objects.filter(id=kwargs["pk"], created_by=request.user).exists()
if obj:
return view_func(instance, request, *args, **kwargs)
# Convert allowed_roles to their values if they are enum members
allowed_role_values = [
role.value if isinstance(role, ROLE) else role for role in allowed_roles
]
allowed_role_values = [role.value if isinstance(role, ROLE) else role for role in allowed_roles]
# Check role permissions
if level == "WORKSPACE":
@@ -39,13 +35,31 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
).exists():
return view_func(instance, request, *args, **kwargs)
else:
if ProjectMember.objects.filter(
is_user_has_allowed_role = ProjectMember.objects.filter(
member=request.user,
workspace__slug=kwargs["slug"],
project_id=kwargs["project_id"],
role__in=allowed_role_values,
is_active=True,
).exists():
).exists()
# Return if the user has the allowed role else if they are workspace admin and part of the project regardless of the role # noqa: E501
if is_user_has_allowed_role:
return view_func(instance, request, *args, **kwargs)
elif (
ProjectMember.objects.filter(
member=request.user,
workspace__slug=kwargs["slug"],
project_id=kwargs["project_id"],
is_active=True,
).exists()
and WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=kwargs["slug"],
role=ROLE.ADMIN.value,
is_active=True,
).exists()
):
return view_func(instance, request, *args, **kwargs)
# Return permission denied if no conditions are met
+121
View File
@@ -0,0 +1,121 @@
from plane.db.models import ProjectMember, Page
from plane.app.permissions import ROLE
from rest_framework.permissions import BasePermission, SAFE_METHODS
# Permission Mappings for workspace members
ADMIN = ROLE.ADMIN.value
MEMBER = ROLE.MEMBER.value
GUEST = ROLE.GUEST.value
class ProjectPagePermission(BasePermission):
"""
Custom permission to control access to pages within a workspace
based on user roles, page visibility (public/private), and feature flags.
"""
def has_permission(self, request, view):
"""
Check basic project-level permissions before checking object-level permissions.
"""
if request.user.is_anonymous:
return False
user_id = request.user.id
slug = view.kwargs.get("slug")
page_id = view.kwargs.get("page_id")
project_id = view.kwargs.get("project_id")
# Hook for extended validation
extended_access, role = self._check_access_and_get_role(request, slug, project_id)
if extended_access is False:
return False
if page_id:
page = Page.objects.get(id=page_id, workspace__slug=slug)
# Allow access if the user is the owner of the page
if page.owned_by_id == user_id:
return True
# Handle private page access
if page.access == Page.PRIVATE_ACCESS:
return self._has_private_page_action_access(request, slug, page, project_id)
# Handle public page access
return self._has_public_page_action_access(request, role)
def _check_project_member_access(self, request, slug, project_id):
"""
Check if the user is a project member.
"""
return (
ProjectMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
project_id=project_id,
)
.values_list("role", flat=True)
.first()
)
def _check_access_and_get_role(self, request, slug, project_id):
"""
Hook for extended access checking
Returns: True (allow), False (deny), None (continue with normal flow)
"""
role = self._check_project_member_access(request, slug, project_id)
if not role:
return False, None
return True, role
def _has_private_page_action_access(self, request, slug, page, project_id):
"""
Check access to private pages. Override for feature flag logic.
"""
# Base implementation: only owner can access private pages
return False
def _check_project_action_access(self, request, role):
method = request.method
# Only admins can create (POST) pages
if method == "POST":
if role in [ADMIN, MEMBER]:
return True
return False
# Safe methods (GET, HEAD, OPTIONS) allowed for all active roles
if method in SAFE_METHODS:
if role in [ADMIN, MEMBER, GUEST]:
return True
return False
# PUT/PATCH: Admins and members can update
if method in ["PUT", "PATCH"]:
if role in [ADMIN, MEMBER]:
return True
return False
# DELETE: Only admins can delete
if method == "DELETE":
if role in [ADMIN]:
return True
return False
# Deny by default
return False
def _has_public_page_action_access(self, request, role):
"""
Check if the user has permission to access a public page
and can perform operations on the page.
"""
project_member_exists = self._check_project_action_access(request, role)
if not project_member_exists:
return False
return True
+22 -13
View File
@@ -3,11 +3,7 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission
# Module import
from plane.db.models import ProjectMember, WorkspaceMember
# Permission Mappings
Admin = 20
Member = 15
Guest = 5
from plane.db.models.project import ROLE
class ProjectBasePermission(BasePermission):
@@ -26,18 +22,31 @@ class ProjectBasePermission(BasePermission):
return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[Admin, Member],
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
is_active=True,
).exists()
## Only Project Admins can update project attributes
return ProjectMember.objects.filter(
project_member_qs = ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role=Admin,
project_id=view.project_id,
is_active=True,
).exists()
)
## Only project admins or workspace admin who is part of the project can access
if project_member_qs.filter(role=ROLE.ADMIN.value).exists():
return True
else:
return (
project_member_qs.exists()
and WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=view.workspace_slug,
role=ROLE.ADMIN.value,
is_active=True,
).exists()
)
class ProjectMemberPermission(BasePermission):
@@ -55,7 +64,7 @@ class ProjectMemberPermission(BasePermission):
return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[Admin, Member],
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
is_active=True,
).exists()
@@ -63,7 +72,7 @@ class ProjectMemberPermission(BasePermission):
return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[Admin, Member],
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
project_id=view.project_id,
is_active=True,
).exists()
@@ -97,7 +106,7 @@ class ProjectEntityPermission(BasePermission):
return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[Admin, Member],
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
project_id=view.project_id,
is_active=True,
).exists()
@@ -92,8 +92,6 @@ from .importer import ImporterSerializer
from .page import (
PageSerializer,
PageLogSerializer,
SubPageSerializer,
PageDetailSerializer,
PageVersionSerializer,
PageBinaryUpdateSerializer,
+3 -9
View File
@@ -168,13 +168,9 @@ class DynamicBaseSerializer(BaseSerializer):
# Check if field in expansion then expand the field
if expand in expansion:
if isinstance(response.get(expand), list):
exp_serializer = expansion[expand](
getattr(instance, expand), many=True
)
exp_serializer = expansion[expand](getattr(instance, expand), many=True)
else:
exp_serializer = expansion[expand](
getattr(instance, expand)
)
exp_serializer = expansion[expand](getattr(instance, expand))
response[expand] = exp_serializer.data
else:
# You might need to handle this case differently
@@ -194,9 +190,7 @@ class DynamicBaseSerializer(BaseSerializer):
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
# Serialize issue_attachments and add them to the response
response["issue_attachments"] = IssueAttachmentLiteSerializer(
issue_attachments, many=True
).data
response["issue_attachments"] = IssueAttachmentLiteSerializer(issue_attachments, many=True).data
else:
response["issue_attachments"] = []
+1 -4
View File
@@ -16,10 +16,7 @@ class CycleWriteSerializer(BaseSerializer):
and data.get("start_date", None) > data.get("end_date", None)
):
raise serializers.ValidationError("Start date cannot exceed end date")
if (
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
):
if data.get("start_date", None) is not None and data.get("end_date", None) is not None:
project_id = (
self.initial_data.get("project_id", None)
or (self.instance and self.instance.project_id)
+10 -26
View File
@@ -1,5 +1,3 @@
from lxml import html
# Django imports
from django.utils import timezone
@@ -76,13 +74,9 @@ class DraftIssueCreateSerializer(BaseSerializer):
# Validate description content for security
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
attrs["description_html"]
)
is_valid, error_msg, sanitized_html = validate_html_content(attrs["description_html"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
raise serializers.ValidationError({"error": "html content is not valid"})
# Update the attrs with sanitized HTML if available
if sanitized_html is not None:
attrs["description_html"] = sanitized_html
@@ -90,9 +84,7 @@ class DraftIssueCreateSerializer(BaseSerializer):
if "description_binary" in attrs and attrs["description_binary"]:
is_valid, error_msg = validate_binary_data(attrs["description_binary"])
if not is_valid:
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
# Validate assignees are from project
if attrs.get("assignee_ids", []):
@@ -107,9 +99,9 @@ class DraftIssueCreateSerializer(BaseSerializer):
if attrs.get("label_ids"):
label_ids = [label.id for label in attrs["label_ids"]]
attrs["label_ids"] = list(
Label.objects.filter(
project_id=self.context.get("project_id"), id__in=label_ids
).values_list("id", flat=True)
Label.objects.filter(project_id=self.context.get("project_id"), id__in=label_ids).values_list(
"id", flat=True
)
)
# # Check state is from the project only else raise validation error
@@ -120,9 +112,7 @@ class DraftIssueCreateSerializer(BaseSerializer):
pk=attrs.get("state").id,
).exists()
):
raise serializers.ValidationError(
"State is not valid please pass a valid state_id"
)
raise serializers.ValidationError("State is not valid please pass a valid state_id")
# # Check parent issue is from workspace as it can be cross workspace
if (
@@ -132,9 +122,7 @@ class DraftIssueCreateSerializer(BaseSerializer):
pk=attrs.get("parent").id,
).exists()
):
raise serializers.ValidationError(
"Parent is not valid issue_id please pass a valid issue_id"
)
raise serializers.ValidationError("Parent is not valid issue_id please pass a valid issue_id")
if (
attrs.get("estimate_point")
@@ -143,9 +131,7 @@ class DraftIssueCreateSerializer(BaseSerializer):
pk=attrs.get("estimate_point").id,
).exists()
):
raise serializers.ValidationError(
"Estimate point is not valid please pass a valid estimate_point_id"
)
raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id")
return attrs
@@ -160,9 +146,7 @@ class DraftIssueCreateSerializer(BaseSerializer):
project_id = self.context["project_id"]
# Create Issue
issue = DraftIssue.objects.create(
**validated_data, workspace_id=workspace_id, project_id=project_id
)
issue = DraftIssue.objects.create(**validated_data, workspace_id=workspace_id, project_id=project_id)
# Issue Audit Users
created_by_id = issue.created_by_id
+1 -3
View File
@@ -17,9 +17,7 @@ class PageFavoriteLiteSerializer(serializers.ModelSerializer):
fields = ["id", "name", "logo_props", "project_id"]
def get_project_id(self, obj):
project = (
obj.projects.first()
) # This gets the first project related to the Page
project = obj.projects.first() # This gets the first project related to the Page
return project.id if project else None
+1 -3
View File
@@ -45,9 +45,7 @@ class IntakeIssueSerializer(BaseSerializer):
class IntakeIssueDetailSerializer(BaseSerializer):
issue = IssueDetailSerializer(read_only=True)
duplicate_issue_detail = IssueIntakeSerializer(
read_only=True, source="duplicate_to"
)
duplicate_issue_detail = IssueIntakeSerializer(read_only=True, source="duplicate_to")
class Meta:
model = IntakeIssue
+35 -52
View File
@@ -1,5 +1,3 @@
from lxml import html
# Django imports
from django.utils import timezone
from django.core.validators import URLValidator
@@ -128,13 +126,9 @@ class IssueCreateSerializer(BaseSerializer):
# Validate description content for security
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
attrs["description_html"]
)
is_valid, error_msg, sanitized_html = validate_html_content(attrs["description_html"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
raise serializers.ValidationError({"error": "html content is not valid"})
# Update the attrs with sanitized HTML if available
if sanitized_html is not None:
attrs["description_html"] = sanitized_html
@@ -142,9 +136,7 @@ class IssueCreateSerializer(BaseSerializer):
if "description_binary" in attrs and attrs["description_binary"]:
is_valid, error_msg = validate_binary_data(attrs["description_binary"])
if not is_valid:
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
# Validate assignees are from project
if attrs.get("assignee_ids", []):
@@ -173,9 +165,7 @@ class IssueCreateSerializer(BaseSerializer):
pk=attrs.get("state").id,
).exists()
):
raise serializers.ValidationError(
"State is not valid please pass a valid state_id"
)
raise serializers.ValidationError("State is not valid please pass a valid state_id")
# Check parent issue is from workspace as it can be cross workspace
if (
@@ -185,9 +175,7 @@ class IssueCreateSerializer(BaseSerializer):
pk=attrs.get("parent").id,
).exists()
):
raise serializers.ValidationError(
"Parent is not valid issue_id please pass a valid issue_id"
)
raise serializers.ValidationError("Parent is not valid issue_id please pass a valid issue_id")
if (
attrs.get("estimate_point")
@@ -196,9 +184,7 @@ class IssueCreateSerializer(BaseSerializer):
pk=attrs.get("estimate_point").id,
).exists()
):
raise serializers.ValidationError(
"Estimate point is not valid please pass a valid estimate_point_id"
)
raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id")
return attrs
@@ -344,11 +330,7 @@ class IssueActivitySerializer(BaseSerializer):
source_data = serializers.SerializerMethodField()
def get_source_data(self, obj):
if (
hasattr(obj, "issue")
and hasattr(obj.issue, "source_data")
and obj.issue.source_data
):
if hasattr(obj, "issue") and hasattr(obj.issue, "source_data") and obj.issue.source_data:
return {
"source": obj.issue.source_data[0].source,
"source_email": obj.issue.source_data[0].source_email,
@@ -398,12 +380,8 @@ class IssueLabelSerializer(BaseSerializer):
class IssueRelationSerializer(BaseSerializer):
id = serializers.UUIDField(source="related_issue.id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(
source="related_issue.project_id", read_only=True
)
sequence_id = serializers.IntegerField(
source="related_issue.sequence_id", read_only=True
)
project_id = serializers.PrimaryKeyRelatedField(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)
@@ -442,9 +420,7 @@ class IssueRelationSerializer(BaseSerializer):
class RelatedIssueSerializer(BaseSerializer):
id = serializers.UUIDField(source="issue.id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(
source="issue.project_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)
relation_type = serializers.CharField(read_only=True)
@@ -586,25 +562,17 @@ class IssueLinkSerializer(BaseSerializer):
# Validation if url already exists
def create(self, validated_data):
if IssueLink.objects.filter(
url=validated_data.get("url"), issue_id=validated_data.get("issue_id")
).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
if IssueLink.objects.filter(url=validated_data.get("url"), issue_id=validated_data.get("issue_id")).exists():
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
return IssueLink.objects.create(**validated_data)
def update(self, instance, validated_data):
if (
IssueLink.objects.filter(
url=validated_data.get("url"), issue_id=instance.issue_id
)
IssueLink.objects.filter(url=validated_data.get("url"), issue_id=instance.issue_id)
.exclude(pk=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
return super().update(instance, validated_data)
@@ -667,16 +635,33 @@ class IssueReactionSerializer(BaseSerializer):
class IssueReactionLiteSerializer(DynamicBaseSerializer):
display_name = serializers.CharField(source="actor.display_name", read_only=True)
class Meta:
model = IssueReaction
fields = ["id", "actor", "issue", "reaction"]
fields = ["id", "actor", "issue", "reaction", "display_name"]
class CommentReactionSerializer(BaseSerializer):
display_name = serializers.CharField(source="actor.display_name", read_only=True)
class Meta:
model = CommentReaction
fields = "__all__"
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"]
fields = [
"id",
"actor",
"comment",
"reaction",
"display_name",
"deleted_at",
"workspace",
"project",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at", "created_by", "updated_by"]
class IssueVoteSerializer(BaseSerializer):
@@ -925,9 +910,7 @@ class IssueDetailSerializer(IssueSerializer):
class IssuePublicSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateLiteSerializer(read_only=True, source="state")
reactions = IssueReactionSerializer(
read_only=True, many=True, source="issue_reactions"
)
reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions")
votes = IssueVoteSerializer(read_only=True, many=True)
class Meta:
+7 -23
View File
@@ -65,9 +65,7 @@ class ModuleWriteSerializer(BaseSerializer):
if module_name:
# Lookup for the module name in the module table for that project
if Module.objects.filter(name=module_name, project=project).exists():
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
raise serializers.ValidationError({"error": "Module with this name already exists"})
module = Module.objects.create(**validated_data, project=project)
if members is not None:
@@ -94,14 +92,8 @@ class ModuleWriteSerializer(BaseSerializer):
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if (
Module.objects.filter(name=module_name, project=instance.project)
.exclude(id=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
if Module.objects.filter(name=module_name, project=instance.project).exclude(id=instance.id).exists():
raise serializers.ValidationError({"error": "Module with this name already exists"})
if members is not None:
ModuleMember.objects.filter(module=instance).delete()
@@ -191,32 +183,24 @@ class ModuleLinkSerializer(BaseSerializer):
def create(self, validated_data):
validated_data["url"] = self.validate_url(validated_data.get("url"))
if ModuleLink.objects.filter(
url=validated_data.get("url"), module_id=validated_data.get("module_id")
).exists():
if ModuleLink.objects.filter(url=validated_data.get("url"), module_id=validated_data.get("module_id")).exists():
raise serializers.ValidationError({"error": "URL already exists."})
return super().create(validated_data)
def update(self, instance, validated_data):
validated_data["url"] = self.validate_url(validated_data.get("url"))
if (
ModuleLink.objects.filter(
url=validated_data.get("url"), module_id=instance.module_id
)
ModuleLink.objects.filter(url=validated_data.get("url"), module_id=instance.module_id)
.exclude(pk=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
raise serializers.ValidationError({"error": "URL already exists for this Issue"})
return super().update(instance, validated_data)
class ModuleSerializer(DynamicBaseSerializer):
member_ids = serializers.ListField(
child=serializers.UUIDField(), required=False, allow_null=True
)
member_ids = serializers.ListField(child=serializers.UUIDField(), required=False, allow_null=True)
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
+1 -31
View File
@@ -10,7 +10,6 @@ from plane.utils.content_validator import (
)
from plane.db.models import (
Page,
PageLog,
PageLabel,
Label,
ProjectPage,
@@ -130,32 +129,6 @@ class PageDetailSerializer(PageSerializer):
fields = PageSerializer.Meta.fields + ["description_html"]
class SubPageSerializer(BaseSerializer):
entity_details = serializers.SerializerMethodField()
class Meta:
model = PageLog
fields = "__all__"
read_only_fields = ["workspace", "page"]
def get_entity_details(self, obj):
entity_name = obj.entity_name
if entity_name == "forward_link" or entity_name == "back_link":
try:
page = Page.objects.get(pk=obj.entity_identifier)
return PageSerializer(page).data
except Page.DoesNotExist:
return None
return None
class PageLogSerializer(BaseSerializer):
class Meta:
model = PageLog
fields = "__all__"
read_only_fields = ["workspace", "page"]
class PageVersionSerializer(BaseSerializer):
class Meta:
model = PageVersion
@@ -212,9 +185,7 @@ class PageBinaryUpdateSerializer(serializers.Serializer):
# Validate the binary data
is_valid, error_message = validate_binary_data(binary_data)
if not is_valid:
raise serializers.ValidationError(
f"Invalid binary data: {error_message}"
)
raise serializers.ValidationError(f"Invalid binary data: {error_message}")
return binary_data
except Exception as e:
@@ -235,7 +206,6 @@ class PageBinaryUpdateSerializer(serializers.Serializer):
# Return sanitized HTML if available, otherwise return original
return sanitized_html if sanitized_html is not None else value
def update(self, instance, validated_data):
"""Update the page instance with validated data"""
if "description_binary" in validated_data:
+5 -18
View File
@@ -15,7 +15,6 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_binary_data,
)
@@ -48,9 +47,7 @@ class ProjectSerializer(BaseSerializer):
project_id = self.instance.id if self.instance else None
workspace_id = self.context["workspace_id"]
project = Project.objects.filter(
identifier=identifier, workspace_id=workspace_id
)
project = Project.objects.filter(identifier=identifier, workspace_id=workspace_id)
if project_id:
project = project.exclude(id=project_id)
@@ -65,17 +62,13 @@ class ProjectSerializer(BaseSerializer):
def validate(self, data):
# Validate description content for security
if "description_html" in data and data["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
str(data["description_html"])
)
is_valid, error_msg, sanitized_html = validate_html_content(str(data["description_html"]))
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
raise serializers.ValidationError({"error": "html content is not valid"})
return data
@@ -84,9 +77,7 @@ class ProjectSerializer(BaseSerializer):
project = Project.objects.create(**validated_data, workspace_id=workspace_id)
ProjectIdentifier.objects.create(
name=project.identifier, project=project, workspace_id=workspace_id
)
ProjectIdentifier.objects.create(name=project.identifier, project=project, workspace_id=workspace_id)
return project
@@ -119,11 +110,7 @@ class ProjectListSerializer(DynamicBaseSerializer):
project_members = getattr(obj, "members_list", None)
if project_members is not None:
# Filter members by the project ID
return [
member.member_id
for member in project_members
if member.is_active and not member.member.is_bot
]
return [member.member_id for member in project_members if member.is_active and not member.member.is_bot]
return []
class Meta:
+42 -33
View File
@@ -4,7 +4,7 @@ from rest_framework import serializers
# Module import
from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite
from plane.utils.url import contains_url
from plane.db.models.user import get_default_tour_completed_features
from .base import BaseSerializer
@@ -91,9 +91,7 @@ class UserMeSettingsSerializer(BaseSerializer):
read_only_fields = fields
def get_workspace(self, obj):
workspace_invites = WorkspaceMemberInvite.objects.filter(
email=obj.email
).count()
workspace_invites = WorkspaceMemberInvite.objects.filter(email=obj.email).count()
# profile
profile = Profile.objects.get(user=obj)
@@ -110,43 +108,27 @@ class UserMeSettingsSerializer(BaseSerializer):
workspace_member__member=obj.id,
workspace_member__is_active=True,
).first()
logo_asset_url = (
workspace.logo_asset.asset_url
if workspace.logo_asset is not None
else ""
)
logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else ""
return {
"last_workspace_id": profile.last_workspace_id,
"last_workspace_slug": (
workspace.slug if workspace is not None else ""
),
"last_workspace_name": (
workspace.name if workspace is not None else ""
),
"last_workspace_slug": (workspace.slug if workspace is not None else ""),
"last_workspace_name": (workspace.name if workspace is not None else ""),
"last_workspace_logo": (logo_asset_url),
"fallback_workspace_id": profile.last_workspace_id,
"fallback_workspace_slug": (
workspace.slug if workspace is not None else ""
),
"fallback_workspace_slug": (workspace.slug if workspace is not None else ""),
"invites": workspace_invites,
}
else:
fallback_workspace = (
Workspace.objects.filter(
workspace_member__member_id=obj.id, workspace_member__is_active=True
)
Workspace.objects.filter(workspace_member__member_id=obj.id, workspace_member__is_active=True)
.order_by("created_at")
.first()
)
return {
"last_workspace_id": None,
"last_workspace_slug": None,
"fallback_workspace_id": (
fallback_workspace.id if fallback_workspace is not None else None
),
"fallback_workspace_slug": (
fallback_workspace.slug if fallback_workspace is not None else None
),
"fallback_workspace_id": (fallback_workspace.id if fallback_workspace is not None else None),
"fallback_workspace_slug": (fallback_workspace.slug if fallback_workspace is not None else None),
"invites": workspace_invites,
}
@@ -195,14 +177,10 @@ class ChangePasswordSerializer(serializers.Serializer):
def validate(self, data):
if data.get("old_password") == data.get("new_password"):
raise serializers.ValidationError(
{"error": "New password cannot be same as old password."}
)
raise serializers.ValidationError({"error": "New password cannot be same as old password."})
if data.get("new_password") != data.get("confirm_password"):
raise serializers.ValidationError(
{"error": "Confirm password should be same as the new password."}
)
raise serializers.ValidationError({"error": "Confirm password should be same as the new password."})
return data
@@ -216,6 +194,37 @@ class ResetPasswordSerializer(serializers.Serializer):
class ProfileSerializer(BaseSerializer):
def validate_tour_completed_features(self, value):
"""
Clean tour_completed_features by removing invalid keys and keeping only
the keys present in get_default_tour_completed_features function.
"""
if not isinstance(value, dict):
return get_default_tour_completed_features()
expected_structure = get_default_tour_completed_features()
# Use dict comprehension for cleaner, more efficient code
cleaned_data = {}
# Process valid top-level keys
for key in expected_structure:
if key in value:
val = value[key]
if isinstance(val, dict) and key in ['workspace_features', 'project_features']:
# Clean nested structure using intersection
expected_nested = expected_structure[key]
cleaned_data[key] = {k: v for k, v in val.items() if k in expected_nested}
else:
cleaned_data[key] = val
else:
# Add missing key with default
default_value = expected_structure[key]
cleaned_data[key] = default_value.copy() if isinstance(default_value, dict) else default_value
return cleaned_data
class Meta:
model = Profile
fields = "__all__"
+13 -41
View File
@@ -21,29 +21,21 @@ class WebhookSerializer(DynamicBaseSerializer):
# Extract the hostname from the URL
hostname = urlparse(url).hostname
if not hostname:
raise serializers.ValidationError(
{"url": "Invalid URL: No hostname found."}
)
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
# Resolve the hostname to IP addresses
try:
ip_addresses = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise serializers.ValidationError(
{"url": "Hostname could not be resolved."}
)
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
if not ip_addresses:
raise serializers.ValidationError(
{"url": "No IP addresses found for the hostname."}
)
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_loopback:
raise serializers.ValidationError(
{"url": "URL resolves to a blocked IP address."}
)
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
# Additional validation for multiple request domains and their subdomains
request = self.context.get("request")
@@ -53,13 +45,8 @@ class WebhookSerializer(DynamicBaseSerializer):
disallowed_domains.append(request_host)
# Check if hostname is a subdomain or exact match of any disallowed domain
if any(
hostname == domain or hostname.endswith("." + domain)
for domain in disallowed_domains
):
raise serializers.ValidationError(
{"url": "URL domain or its subdomain is not allowed."}
)
if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
return Webhook.objects.create(**validated_data)
@@ -69,47 +56,32 @@ class WebhookSerializer(DynamicBaseSerializer):
# Extract the hostname from the URL
hostname = urlparse(url).hostname
if not hostname:
raise serializers.ValidationError(
{"url": "Invalid URL: No hostname found."}
)
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
# Resolve the hostname to IP addresses
try:
ip_addresses = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise serializers.ValidationError(
{"url": "Hostname could not be resolved."}
)
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
if not ip_addresses:
raise serializers.ValidationError(
{"url": "No IP addresses found for the hostname."}
)
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_loopback:
raise serializers.ValidationError(
{"url": "URL resolves to a blocked IP address."}
)
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
# Additional validation for multiple request domains and their subdomains
request = self.context.get("request")
disallowed_domains = ["plane.so"] # Add your disallowed domains here
if request:
request_host = request.get_host().split(":")[
0
] # Remove port if present
request_host = request.get_host().split(":")[0] # Remove port if present
disallowed_domains.append(request_host)
# Check if hostname is a subdomain or exact match of any disallowed domain
if any(
hostname == domain or hostname.endswith("." + domain)
for domain in disallowed_domains
):
raise serializers.ValidationError(
{"url": "URL domain or its subdomain is not allowed."}
)
if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
return super().update(instance, validated_data)
+10 -28
View File
@@ -173,9 +173,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
)
if workspace_user_link.exists():
raise serializers.ValidationError(
{"error": "URL already exists for this workspace and owner"}
)
raise serializers.ValidationError({"error": "URL already exists for this workspace and owner"})
return super().create(validated_data)
@@ -189,9 +187,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
)
if workspace_user_link.exclude(pk=instance.id).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this workspace and owner"}
)
raise serializers.ValidationError({"error": "URL already exists for this workspace and owner"})
return super().update(instance, validated_data)
@@ -219,11 +215,7 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
return project.identifier if project else None
def get_assignees(self, obj):
return list(
obj.assignees.filter(issue_assignee__deleted_at__isnull=True).values_list(
"id", flat=True
)
)
return list(obj.assignees.filter(issue_assignee__deleted_at__isnull=True).values_list("id", flat=True))
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
@@ -234,9 +226,9 @@ class ProjectRecentVisitSerializer(serializers.ModelSerializer):
fields = ["id", "name", "logo_props", "project_members", "identifier"]
def get_project_members(self, obj):
members = ProjectMember.objects.filter(
project_id=obj.id, member__is_bot=False, is_active=True
).values_list("member", flat=True)
members = ProjectMember.objects.filter(project_id=obj.id, member__is_bot=False, is_active=True).values_list(
"member", flat=True
)
return members
@@ -257,11 +249,7 @@ class PageRecentVisitSerializer(serializers.ModelSerializer):
]
def get_project_id(self, obj):
return (
obj.project_id
if hasattr(obj, "project_id")
else obj.projects.values_list("id", flat=True).first()
)
return obj.project_id if hasattr(obj, "project_id") else obj.projects.values_list("id", flat=True).first()
def get_project_identifier(self, obj):
project = obj.projects.first()
@@ -319,13 +307,9 @@ class StickySerializer(BaseSerializer):
def validate(self, data):
# Validate description content for security
if "description_html" in data and data["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
data["description_html"]
)
is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
raise serializers.ValidationError({"error": "html content is not valid"})
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
@@ -333,9 +317,7 @@ class StickySerializer(BaseSerializer):
if "description_binary" in data and data["description_binary"]:
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
return data
+1 -3
View File
@@ -30,9 +30,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/analytic-view/<uuid:pk>/",
AnalyticViewViewset.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
AnalyticViewViewset.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="analytic-view",
),
path(
+1 -3
View File
@@ -21,9 +21,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/",
BulkEstimatePointEndpoint.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
BulkEstimatePointEndpoint.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="bulk-create-estimate-points",
),
path(
+4 -12
View File
@@ -16,9 +16,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intakes/<uuid:pk>/",
IntakeViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
IntakeViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="intake",
),
path(
@@ -28,9 +26,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-issues/<uuid:pk>/",
IntakeIssueViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
IntakeIssueViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="intake-issue",
),
path(
@@ -40,9 +36,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:pk>/",
IntakeViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
IntakeViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="inbox",
),
path(
@@ -52,9 +46,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:pk>/",
IntakeIssueViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
IntakeIssueViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="inbox-issue",
),
path(
+2 -6
View File
@@ -187,9 +187,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/subscribe/",
IssueSubscriberViewSet.as_view(
{"get": "subscription_status", "post": "subscribe", "delete": "unsubscribe"}
),
IssueSubscriberViewSet.as_view({"get": "subscription_status", "post": "subscribe", "delete": "unsubscribe"}),
name="project-issue-subscribers",
),
## End Issue Subscribers
@@ -232,9 +230,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/archive/",
IssueArchiveViewSet.as_view(
{"get": "retrieve", "post": "archive", "delete": "unarchive"}
),
IssueArchiveViewSet.as_view({"get": "retrieve", "post": "archive", "delete": "unarchive"}),
name="project-issue-archive-unarchive",
),
## End Issue Archives
+1 -3
View File
@@ -17,9 +17,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/users/notifications/<uuid:pk>/",
NotificationViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
NotificationViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="notifications",
),
path(
+12 -27
View File
@@ -4,68 +4,53 @@ from django.urls import path
from plane.app.views import (
PageViewSet,
PageFavoriteViewSet,
PageLogEndpoint,
SubPagesEndpoint,
PagesDescriptionViewSet,
PageVersionEndpoint,
PageDuplicateEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages-summary/",
PageViewSet.as_view({"get": "summary"}),
name="project-pages-summary",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
PageViewSet.as_view({"get": "list", "post": "create"}),
name="project-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/",
PageViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/",
PageViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="project-pages",
),
# favorite pages
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/favorite-pages/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/favorite-pages/<uuid:page_id>/",
PageFavoriteViewSet.as_view({"post": "create", "delete": "destroy"}),
name="user-favorite-pages",
),
# archived pages
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/archive/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/archive/",
PageViewSet.as_view({"post": "archive", "delete": "unarchive"}),
name="project-page-archive-unarchive",
),
# lock and unlock
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/lock/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/lock/",
PageViewSet.as_view({"post": "lock", "delete": "unlock"}),
name="project-pages-lock-unlock",
),
# private and public page
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/access/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/access/",
PageViewSet.as_view({"post": "access"}),
name="project-pages-access",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/",
PageLogEndpoint.as_view(),
name="page-transactions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/<uuid:transaction>/",
PageLogEndpoint.as_view(),
name="page-transactions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/sub-pages/",
SubPagesEndpoint.as_view(),
name="sub-page",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/description/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/description/",
PagesDescriptionViewSet.as_view({"get": "retrieve", "patch": "partial_update"}),
name="page-description",
),
+2 -6
View File
@@ -77,9 +77,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/<uuid:pk>/",
ProjectMemberViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
ProjectMemberViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="project-member",
),
path(
@@ -119,9 +117,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/<uuid:pk>/",
DeployBoardViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
DeployBoardViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="project-deploy-board",
),
path(
+1 -3
View File
@@ -12,9 +12,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:pk>/",
StateViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
StateViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="project-state",
),
path(
+4 -12
View File
@@ -21,9 +21,7 @@ urlpatterns = [
# User Profile
path(
"users/me/",
UserEndpoint.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "deactivate"}
),
UserEndpoint.as_view({"get": "retrieve", "patch": "partial_update", "delete": "deactivate"}),
name="users",
),
path("users/session/", UserSessionEndpoint.as_view(), name="user-session"),
@@ -44,21 +42,15 @@ urlpatterns = [
UserEndpoint.as_view({"get": "retrieve_instance_admin"}),
name="users",
),
path(
"users/me/onboard/", UpdateUserOnBoardedEndpoint.as_view(), name="user-onboard"
),
path("users/me/onboard/", UpdateUserOnBoardedEndpoint.as_view(), name="user-onboard"),
path(
"users/me/tour-completed/",
UpdateUserTourCompletedEndpoint.as_view(),
name="user-tour",
),
path(
"users/me/activities/", UserActivityEndpoint.as_view(), name="user-activities"
),
path("users/me/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
# user workspaces
path(
"users/me/workspaces/", UserWorkSpacesEndpoint.as_view(), name="user-workspace"
),
path("users/me/workspaces/", UserWorkSpacesEndpoint.as_view(), name="user-workspace"),
# User Graphs
path(
"users/me/workspaces/<str:slug>/activity-graph/",
+6 -18
View File
@@ -65,9 +65,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/invitations/<uuid:pk>/",
WorkspaceInvitationsViewset.as_view(
{"delete": "destroy", "get": "retrieve", "patch": "partial_update"}
),
WorkspaceInvitationsViewset.as_view({"delete": "destroy", "get": "retrieve", "patch": "partial_update"}),
name="workspace-invitations",
),
# user workspace invitations
@@ -94,9 +92,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/members/<uuid:pk>/",
WorkSpaceMemberViewSet.as_view(
{"patch": "partial_update", "delete": "destroy", "get": "retrieve"}
),
WorkSpaceMemberViewSet.as_view({"patch": "partial_update", "delete": "destroy", "get": "retrieve"}),
name="workspace-member",
),
path(
@@ -126,9 +122,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/workspace-themes/<uuid:pk>/",
WorkspaceThemeViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
WorkspaceThemeViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="workspace-themes",
),
path(
@@ -208,9 +202,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/draft-issues/<uuid:pk>/",
WorkspaceDraftIssueViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
WorkspaceDraftIssueViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="workspace-drafts-issues",
),
path(
@@ -226,9 +218,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/quick-links/<uuid:pk>/",
QuickLinkViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
QuickLinkViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="workspace-quick-links",
),
# Widgets
@@ -254,9 +244,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/stickies/<uuid:pk>/",
WorkspaceStickyViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
WorkspaceStickyViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="workspace-sticky",
),
# User Preference
-2
View File
@@ -165,8 +165,6 @@ from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint
from .page.base import (
PageViewSet,
PageFavoriteViewSet,
PageLogEndpoint,
SubPagesEndpoint,
PagesDescriptionViewSet,
PageDuplicateEndpoint,
)
+28 -78
View File
@@ -41,26 +41,16 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
def get_filtered_count() -> int:
if self.filters["analytics_date_range"]:
return queryset.filter(
created_at__gte=self.filters["analytics_date_range"]["current"][
"gte"
],
created_at__lte=self.filters["analytics_date_range"]["current"][
"lte"
],
created_at__gte=self.filters["analytics_date_range"]["current"]["gte"],
created_at__lte=self.filters["analytics_date_range"]["current"]["lte"],
).count()
return queryset.count()
def get_previous_count() -> int:
if self.filters["analytics_date_range"] and self.filters[
"analytics_date_range"
].get("previous"):
if self.filters["analytics_date_range"] and self.filters["analytics_date_range"].get("previous"):
return queryset.filter(
created_at__gte=self.filters["analytics_date_range"]["previous"][
"gte"
],
created_at__lte=self.filters["analytics_date_range"]["previous"][
"lte"
],
created_at__gte=self.filters["analytics_date_range"]["previous"]["gte"],
created_at__lte=self.filters["analytics_date_range"]["previous"]["lte"],
).count()
return 0
@@ -71,39 +61,27 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
def get_overview_data(self) -> Dict[str, Dict[str, int]]:
members_query = WorkspaceMember.objects.filter(
workspace__slug=self._workspace_slug, is_active=True
workspace__slug=self._workspace_slug, is_active=True, member__is_bot=False
)
if self.request.GET.get("project_ids", None):
project_ids = self.request.GET.get("project_ids", None)
project_ids = [str(project_id) for project_id in project_ids.split(",")]
members_query = ProjectMember.objects.filter(
project_id__in=project_ids, is_active=True
project_id__in=project_ids, is_active=True, member__is_bot=False
)
return {
"total_users": self.get_filtered_counts(members_query),
"total_admins": self.get_filtered_counts(
members_query.filter(role=ROLE.ADMIN.value)
),
"total_members": self.get_filtered_counts(
members_query.filter(role=ROLE.MEMBER.value)
),
"total_guests": self.get_filtered_counts(
members_query.filter(role=ROLE.GUEST.value)
),
"total_projects": self.get_filtered_counts(
Project.objects.filter(**self.filters["project_filters"])
),
"total_work_items": self.get_filtered_counts(
Issue.issue_objects.filter(**self.filters["base_filters"])
),
"total_cycles": self.get_filtered_counts(
Cycle.objects.filter(**self.filters["base_filters"])
),
"total_admins": self.get_filtered_counts(members_query.filter(role=ROLE.ADMIN.value)),
"total_members": self.get_filtered_counts(members_query.filter(role=ROLE.MEMBER.value)),
"total_guests": self.get_filtered_counts(members_query.filter(role=ROLE.GUEST.value)),
"total_projects": self.get_filtered_counts(Project.objects.filter(**self.filters["project_filters"])),
"total_work_items": self.get_filtered_counts(Issue.issue_objects.filter(**self.filters["base_filters"])),
"total_cycles": self.get_filtered_counts(Cycle.objects.filter(**self.filters["base_filters"])),
"total_intake": self.get_filtered_counts(
Issue.objects.filter(**self.filters["base_filters"]).filter(
issue_intake__status__in=["-2", "0"]
issue_intake__status__in=["-2", "-1", "0", "1", "2"] # TODO: Add description for reference.
)
),
}
@@ -113,18 +91,10 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
return {
"total_work_items": self.get_filtered_counts(base_queryset),
"started_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="started")
),
"backlog_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="backlog")
),
"un_started_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="unstarted")
),
"completed_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="completed")
),
"started_work_items": self.get_filtered_counts(base_queryset.filter(state__group="started")),
"backlog_work_items": self.get_filtered_counts(base_queryset.filter(state__group="backlog")),
"un_started_work_items": self.get_filtered_counts(base_queryset.filter(state__group="unstarted")),
"completed_work_items": self.get_filtered_counts(base_queryset.filter(state__group="completed")),
}
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
@@ -153,9 +123,7 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView):
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
base_queryset = base_queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
base_queryset = base_queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date)
return (
base_queryset.values("project_id", "project__name")
@@ -212,24 +180,16 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
}
total_work_items = base_queryset.filter(**date_filter).count()
total_cycles = Cycle.objects.filter(
**self.filters["base_filters"], **date_filter
).count()
total_modules = Module.objects.filter(
**self.filters["base_filters"], **date_filter
).count()
total_cycles = Cycle.objects.filter(**self.filters["base_filters"], **date_filter).count()
total_modules = Module.objects.filter(**self.filters["base_filters"], **date_filter).count()
total_intake = Issue.objects.filter(
issue_intake__isnull=False, **self.filters["base_filters"], **date_filter
).count()
total_members = WorkspaceMember.objects.filter(
workspace__slug=self._workspace_slug, is_active=True, **date_filter
).count()
total_pages = ProjectPage.objects.filter(
**self.filters["base_filters"], **date_filter
).count()
total_views = IssueView.objects.filter(
**self.filters["base_filters"], **date_filter
).count()
total_pages = ProjectPage.objects.filter(**self.filters["base_filters"], **date_filter).count()
total_views = IssueView.objects.filter(**self.filters["base_filters"], **date_filter).count()
data = {
"work_items": total_work_items,
@@ -255,9 +215,7 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
queryset = (
Issue.issue_objects.filter(**self.filters["base_filters"])
.select_related("workspace", "state", "parent")
.prefetch_related(
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
)
.prefetch_related("assignees", "labels", "issue_module__module", "issue_cycle__cycle")
)
workspace = Workspace.objects.get(slug=self._workspace_slug)
@@ -266,9 +224,7 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
queryset = queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
queryset = queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date)
# Annotate by month and count
monthly_stats = (
@@ -311,9 +267,7 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
)
# Move to next month
if current_month.month == 12:
current_month = current_month.replace(
year=current_month.year + 1, month=1
)
current_month = current_month.replace(year=current_month.year + 1, month=1)
else:
current_month = current_month.replace(month=current_month.month + 1)
@@ -338,17 +292,13 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
queryset = (
Issue.issue_objects.filter(**self.filters["base_filters"])
.select_related("workspace", "state", "parent")
.prefetch_related(
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
)
.prefetch_related("assignees", "labels", "issue_module__module", "issue_cycle__cycle")
)
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
queryset = queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
queryset = queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date)
return Response(
build_analytics_chart(queryset, x_axis, group_by),
+19 -64
View File
@@ -55,25 +55,16 @@ class AnalyticsEndpoint(BaseAPIView):
valid_yaxis = ["issue_count", "estimate"]
# Check for x-axis and y-axis as thery are required parameters
if (
not x_axis
or not y_axis
or x_axis not in valid_xaxis_segment
or y_axis not in valid_yaxis
):
if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis:
return Response(
{
"error": "x-axis and y-axis dimensions are required and the values should be valid"
},
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
status=status.HTTP_400_BAD_REQUEST,
)
# If segment is present it cannot be same as x-axis
if segment and (segment not in valid_xaxis_segment or x_axis == segment):
return Response(
{
"error": "Both segment and x axis cannot be same and segment should be valid"
},
{"error": "Both segment and x axis cannot be same and segment should be valid"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -87,9 +78,7 @@ class AnalyticsEndpoint(BaseAPIView):
total_issues = queryset.count()
# Build the graph payload
distribution = build_graph_plot(
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
)
distribution = build_graph_plot(queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment)
state_details = {}
if x_axis in ["state_id"] or segment in ["state_id"]:
@@ -118,10 +107,7 @@ class AnalyticsEndpoint(BaseAPIView):
if x_axis in ["assignees__id"] or segment in ["assignees__id"]:
assignee_details = (
Issue.issue_objects.filter(
Q(
Q(assignees__avatar__isnull=False)
| Q(assignees__avatar_asset__isnull=False)
),
Q(Q(assignees__avatar__isnull=False) | Q(assignees__avatar_asset__isnull=False)),
workspace__slug=slug,
**filters,
)
@@ -171,9 +157,7 @@ class AnalyticsEndpoint(BaseAPIView):
)
module_details = {}
if x_axis in ["issue_module__module_id"] or segment in [
"issue_module__module_id"
]:
if x_axis in ["issue_module__module_id"] or segment in ["issue_module__module_id"]:
module_details = (
Issue.issue_objects.filter(
workspace__slug=slug,
@@ -212,9 +196,7 @@ class AnalyticViewViewset(BaseViewSet):
serializer.save(workspace_id=workspace.id)
def get_queryset(self):
return self.filter_queryset(
super().get_queryset().filter(workspace__slug=self.kwargs.get("slug"))
)
return self.filter_queryset(super().get_queryset().filter(workspace__slug=self.kwargs.get("slug")))
class SavedAnalyticEndpoint(BaseAPIView):
@@ -235,9 +217,7 @@ class SavedAnalyticEndpoint(BaseAPIView):
)
segment = request.GET.get("segment", False)
distribution = build_graph_plot(
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
)
distribution = build_graph_plot(queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment)
total_issues = queryset.count()
return Response(
{"total": total_issues, "distribution": distribution},
@@ -270,36 +250,23 @@ class ExportAnalyticsEndpoint(BaseAPIView):
valid_yaxis = ["issue_count", "estimate"]
# Check for x-axis and y-axis as thery are required parameters
if (
not x_axis
or not y_axis
or x_axis not in valid_xaxis_segment
or y_axis not in valid_yaxis
):
if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis:
return Response(
{
"error": "x-axis and y-axis dimensions are required and the values should be valid"
},
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
status=status.HTTP_400_BAD_REQUEST,
)
# If segment is present it cannot be same as x-axis
if segment and (segment not in valid_xaxis_segment or x_axis == segment):
return Response(
{
"error": "Both segment and x axis cannot be same and segment should be valid"
},
{"error": "Both segment and x axis cannot be same and segment should be valid"},
status=status.HTTP_400_BAD_REQUEST,
)
analytic_export_task.delay(
email=request.user.email, data=request.data, slug=slug
)
analytic_export_task.delay(email=request.user.email, data=request.data, slug=slug)
return Response(
{
"message": f"Once the export is ready it will be emailed to you at {str(request.user.email)}"
},
{"message": f"Once the export is ready it will be emailed to you at {str(request.user.email)}"},
status=status.HTTP_200_OK,
)
@@ -315,9 +282,7 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
state_groups = base_issues.annotate(state_group=F("state__group"))
total_issues_classified = (
state_groups.values("state_group")
.annotate(state_count=Count("state_group"))
.order_by("state_group")
state_groups.values("state_group").annotate(state_count=Count("state_group")).order_by("state_group")
)
open_issues_groups = ["backlog", "unstarted", "started"]
@@ -362,9 +327,7 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
created_by__avatar_asset__isnull=True, then="created_by__avatar"
),
When(created_by__avatar_asset__isnull=True, then="created_by__avatar"),
default=Value(None),
output_field=models.CharField(),
)
@@ -395,9 +358,7 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True, then="assignees__avatar"
),
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
default=Value(None),
output_field=models.CharField(),
)
@@ -422,9 +383,7 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True, then="assignees__avatar"
),
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
default=Value(None),
output_field=models.CharField(),
)
@@ -485,9 +444,7 @@ class ProjectStatsEndpoint(BaseAPIView):
if "completed_issues" in requested_fields:
annotations["completed_issues"] = (
Issue.issue_objects.filter(
project_id=OuterRef("pk"), state__group="completed"
)
Issue.issue_objects.filter(project_id=OuterRef("pk"), state__group__in=["completed", "cancelled"])
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -511,9 +468,7 @@ class ProjectStatsEndpoint(BaseAPIView):
if "total_members" in requested_fields:
annotations["total_members"] = (
ProjectMember.objects.filter(
project_id=OuterRef("id"), member__is_bot=False, is_active=True
)
ProjectMember.objects.filter(project_id=OuterRef("id"), member__is_bot=False, is_active=True)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -42,12 +42,8 @@ class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
def get_filtered_count() -> int:
if self.filters["analytics_date_range"]:
return queryset.filter(
created_at__gte=self.filters["analytics_date_range"]["current"][
"gte"
],
created_at__lte=self.filters["analytics_date_range"]["current"][
"lte"
],
created_at__gte=self.filters["analytics_date_range"]["current"]["gte"],
created_at__lte=self.filters["analytics_date_range"]["current"]["lte"],
).count()
return queryset.count()
@@ -55,42 +51,30 @@ class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
"count": get_filtered_count(),
}
def get_work_items_stats(
self, project_id, cycle_id=None, module_id=None
) -> Dict[str, Dict[str, int]]:
def get_work_items_stats(self, project_id, cycle_id=None, module_id=None) -> Dict[str, Dict[str, int]]:
"""
Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
"""
base_queryset = None
if cycle_id is not None:
cycle_issues = CycleIssue.objects.filter(
**self.filters["base_filters"], cycle_id=cycle_id
).values_list("issue_id", flat=True)
cycle_issues = CycleIssue.objects.filter(**self.filters["base_filters"], cycle_id=cycle_id).values_list(
"issue_id", flat=True
)
base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
elif module_id is not None:
module_issues = ModuleIssue.objects.filter(
**self.filters["base_filters"], module_id=module_id
).values_list("issue_id", flat=True)
module_issues = ModuleIssue.objects.filter(**self.filters["base_filters"], module_id=module_id).values_list(
"issue_id", flat=True
)
base_queryset = Issue.issue_objects.filter(id__in=module_issues)
else:
base_queryset = Issue.issue_objects.filter(
**self.filters["base_filters"], project_id=project_id
)
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"], project_id=project_id)
return {
"total_work_items": self.get_filtered_counts(base_queryset),
"started_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="started")
),
"backlog_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="backlog")
),
"un_started_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="unstarted")
),
"completed_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="completed")
),
"started_work_items": self.get_filtered_counts(base_queryset.filter(state__group="started")),
"backlog_work_items": self.get_filtered_counts(base_queryset.filter(state__group="backlog")),
"un_started_work_items": self.get_filtered_counts(base_queryset.filter(state__group="unstarted")),
"completed_work_items": self.get_filtered_counts(base_queryset.filter(state__group="completed")),
}
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@@ -101,9 +85,7 @@ class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
cycle_id = request.GET.get("cycle_id", None)
module_id = request.GET.get("module_id", None)
return Response(
self.get_work_items_stats(
cycle_id=cycle_id, module_id=module_id, project_id=project_id
),
self.get_work_items_stats(cycle_id=cycle_id, module_id=module_id, project_id=project_id),
status=status.HTTP_200_OK,
)
@@ -116,9 +98,7 @@ class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
base_queryset = base_queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
base_queryset = base_queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date)
return (
base_queryset.values("project_id", "project__name")
@@ -132,24 +112,20 @@ class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
.order_by("project_id")
)
def get_work_items_stats(
self, project_id, cycle_id=None, module_id=None
) -> Dict[str, Dict[str, int]]:
def get_work_items_stats(self, project_id, cycle_id=None, module_id=None) -> Dict[str, Dict[str, int]]:
base_queryset = None
if cycle_id is not None:
cycle_issues = CycleIssue.objects.filter(
**self.filters["base_filters"], cycle_id=cycle_id
).values_list("issue_id", flat=True)
cycle_issues = CycleIssue.objects.filter(**self.filters["base_filters"], cycle_id=cycle_id).values_list(
"issue_id", flat=True
)
base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
elif module_id is not None:
module_issues = ModuleIssue.objects.filter(
**self.filters["base_filters"], module_id=module_id
).values_list("issue_id", flat=True)
module_issues = ModuleIssue.objects.filter(**self.filters["base_filters"], module_id=module_id).values_list(
"issue_id", flat=True
)
base_queryset = Issue.issue_objects.filter(id__in=module_issues)
else:
base_queryset = Issue.issue_objects.filter(
**self.filters["base_filters"], project_id=project_id
)
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"], project_id=project_id)
return (
base_queryset.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
@@ -166,30 +142,18 @@ class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True, then="assignees__avatar"
),
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(
cancelled_work_items=Count(
"id", filter=Q(state__group="cancelled"), distinct=True
),
completed_work_items=Count(
"id", filter=Q(state__group="completed"), distinct=True
),
backlog_work_items=Count(
"id", filter=Q(state__group="backlog"), distinct=True
),
un_started_work_items=Count(
"id", filter=Q(state__group="unstarted"), distinct=True
),
started_work_items=Count(
"id", filter=Q(state__group="started"), distinct=True
),
cancelled_work_items=Count("id", filter=Q(state__group="cancelled"), distinct=True),
completed_work_items=Count("id", filter=Q(state__group="completed"), distinct=True),
backlog_work_items=Count("id", filter=Q(state__group="backlog"), distinct=True),
un_started_work_items=Count("id", filter=Q(state__group="unstarted"), distinct=True),
started_work_items=Count("id", filter=Q(state__group="started"), distinct=True),
)
.order_by("display_name")
)
@@ -204,9 +168,7 @@ class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
cycle_id = request.GET.get("cycle_id", None)
module_id = request.GET.get("module_id", None)
return Response(
self.get_work_items_stats(
project_id=project_id, cycle_id=cycle_id, module_id=module_id
),
self.get_work_items_stats(project_id=project_id, cycle_id=cycle_id, module_id=module_id),
status=status.HTTP_200_OK,
)
@@ -214,23 +176,19 @@ class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
def work_item_completion_chart(
self, project_id, cycle_id=None, module_id=None
) -> Dict[str, Any]:
def work_item_completion_chart(self, project_id, cycle_id=None, module_id=None) -> Dict[str, Any]:
# Get the base queryset
queryset = (
Issue.issue_objects.filter(**self.filters["base_filters"])
.filter(project_id=project_id)
.select_related("workspace", "state", "parent")
.prefetch_related(
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
)
.prefetch_related("assignees", "labels", "issue_module__module", "issue_cycle__cycle")
)
if cycle_id is not None:
cycle_issues = CycleIssue.objects.filter(
**self.filters["base_filters"], cycle_id=cycle_id
).values_list("issue_id", flat=True)
cycle_issues = CycleIssue.objects.filter(**self.filters["base_filters"], cycle_id=cycle_id).values_list(
"issue_id", flat=True
)
cycle = Cycle.objects.filter(id=cycle_id).first()
if cycle and cycle.start_date:
start_date = cycle.start_date.date()
@@ -240,9 +198,9 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
queryset = cycle_issues
elif module_id is not None:
module_issues = ModuleIssue.objects.filter(
**self.filters["base_filters"], module_id=module_id
).values_list("issue_id", flat=True)
module_issues = ModuleIssue.objects.filter(**self.filters["base_filters"], module_id=module_id).values_list(
"issue_id", flat=True
)
module = Module.objects.filter(id=module_id).first()
if module and module.start_date:
start_date = module.start_date
@@ -264,9 +222,7 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
queryset.values("created_at__date")
.annotate(
created_count=Count("id"),
completed_count=Count(
"id", filter=Q(issue__state__group="completed")
),
completed_count=Count("id", filter=Q(issue__state__group="completed")),
)
.order_by("created_at__date")
)
@@ -285,9 +241,7 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
current_date = start_date
while current_date <= end_date:
date_str = current_date.strftime("%Y-%m-%d")
stats = stats_dict.get(
date_str, {"created_count": 0, "completed_count": 0}
)
stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0})
data.append(
{
"key": date_str,
@@ -302,9 +256,7 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
queryset = queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
queryset = queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date)
# Annotate by month and count
monthly_stats = (
@@ -335,9 +287,7 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
while current_month <= last_month:
date_str = current_month.strftime("%Y-%m-%d")
stats = stats_dict.get(
date_str, {"created_count": 0, "completed_count": 0}
)
stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0})
data.append(
{
"key": date_str,
@@ -349,9 +299,7 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
)
# Move to next month
if current_month.month == 12:
current_month = current_month.replace(
year=current_month.year + 1, month=1
)
current_month = current_month.replace(year=current_month.year + 1, month=1)
else:
current_month = current_month.replace(month=current_month.month + 1)
@@ -376,16 +324,14 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
Issue.issue_objects.filter(**self.filters["base_filters"])
.filter(project_id=project_id)
.select_related("workspace", "state", "parent")
.prefetch_related(
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
)
.prefetch_related("assignees", "labels", "issue_module__module", "issue_cycle__cycle")
)
# Apply cycle/module filters if present
if cycle_id is not None:
cycle_issues = CycleIssue.objects.filter(
**self.filters["base_filters"], cycle_id=cycle_id
).values_list("issue_id", flat=True)
cycle_issues = CycleIssue.objects.filter(**self.filters["base_filters"], cycle_id=cycle_id).values_list(
"issue_id", flat=True
)
queryset = queryset.filter(id__in=cycle_issues)
elif module_id is not None:
@@ -397,9 +343,7 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
queryset = queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
queryset = queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date)
return Response(
build_analytics_chart(queryset, x_axis, group_by),
@@ -412,9 +356,7 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
module_id = request.GET.get("module_id", None)
return Response(
self.work_item_completion_chart(
project_id=project_id, cycle_id=cycle_id, module_id=module_id
),
self.work_item_completion_chart(project_id=project_id, cycle_id=cycle_id, module_id=module_id),
status=status.HTTP_200_OK,
)
+2 -6
View File
@@ -65,9 +65,7 @@ class ServiceApiTokenEndpoint(BaseAPIView):
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()
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)
@@ -83,6 +81,4 @@ class ServiceApiTokenEndpoint(BaseAPIView):
user_type=user_type,
is_service=True,
)
return Response(
{"token": str(api_token.token)}, status=status.HTTP_201_CREATED
)
return Response({"token": str(api_token.token)}, status=status.HTTP_201_CREATED)
+3 -9
View File
@@ -20,12 +20,8 @@ class FileAssetEndpoint(BaseAPIView):
asset_key = str(workspace_id) + "/" + asset_key
files = FileAsset.objects.filter(asset=asset_key)
if files.exists():
serializer = FileAssetSerializer(
files, context={"request": request}, many=True
)
return Response(
{"data": serializer.data, "status": True}, status=status.HTTP_200_OK
)
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK)
else:
return Response(
{"error": "Asset key does not exist", "status": False},
@@ -65,9 +61,7 @@ class UserAssetsEndpoint(BaseAPIView):
files = FileAsset.objects.filter(asset=asset_key, created_by=request.user)
if files.exists():
serializer = FileAssetSerializer(files, context={"request": request})
return Response(
{"data": serializer.data, "status": True}, status=status.HTTP_200_OK
)
return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK)
else:
return Response(
{"error": "Asset key does not exist", "status": False},
+18 -54
View File
@@ -44,9 +44,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
# Save the new avatar
user.avatar_asset_id = asset_id
user.save()
invalidate_cache_directly(
path="/api/users/me/", url_params=False, user=True, request=request
)
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
@@ -64,9 +62,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
# Save the new cover image
user.cover_image_asset_id = asset_id
user.save()
invalidate_cache_directly(
path="/api/users/me/", url_params=False, user=True, request=request
)
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
@@ -82,9 +78,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
user = User.objects.get(id=asset.user_id)
user.avatar_asset_id = None
user.save()
invalidate_cache_directly(
path="/api/users/me/", url_params=False, user=True, request=request
)
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
@@ -97,9 +91,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
user = User.objects.get(id=asset.user_id)
user.cover_image_asset_id = None
user.save()
invalidate_cache_directly(
path="/api/users/me/", url_params=False, user=True, request=request
)
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
@@ -159,9 +151,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
# Return the presigned URL
return Response(
{
@@ -198,9 +188,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
self.entity_asset_delete(
entity_type=asset.entity_type, asset=asset, request=request
)
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -264,18 +252,14 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
workspace.logo = ""
workspace.logo_asset_id = asset_id
workspace.save()
invalidate_cache_directly(
path="/api/workspaces/", url_params=False, user=False, request=request
)
invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request)
invalidate_cache_directly(
path="/api/users/me/workspaces/",
url_params=False,
user=True,
request=request,
)
invalidate_cache_directly(
path="/api/instances/", url_params=False, user=False, request=request
)
invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request)
return
# Project Cover
@@ -302,18 +286,14 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
return
workspace.logo_asset_id = None
workspace.save()
invalidate_cache_directly(
path="/api/workspaces/", url_params=False, user=False, request=request
)
invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request)
invalidate_cache_directly(
path="/api/users/me/workspaces/",
url_params=False,
user=True,
request=request,
)
invalidate_cache_directly(
path="/api/instances/", url_params=False, user=False, request=request
)
invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request)
return
# Project Cover
elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
@@ -374,17 +354,13 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
workspace=workspace,
created_by=request.user,
entity_type=entity_type,
**self.get_entity_id_field(
entity_type=entity_type, entity_id=entity_identifier
),
**self.get_entity_id_field(entity_type=entity_type, entity_id=entity_identifier),
)
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
# Return the presigned URL
return Response(
{
@@ -421,9 +397,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
self.entity_asset_delete(
entity_type=asset.entity_type, asset=asset, request=request
)
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -586,9 +560,7 @@ class ProjectAssetEndpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
# Return the presigned URL
return Response(
{
@@ -618,9 +590,7 @@ class ProjectAssetEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def delete(self, request, slug, project_id, pk):
# Get the asset
asset = FileAsset.objects.get(
id=pk, workspace__slug=slug, project_id=project_id
)
asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
# Check deleted assets
asset.is_deleted = True
asset.deleted_at = timezone.now()
@@ -631,9 +601,7 @@ class ProjectAssetEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, pk):
# get the asset id
asset = FileAsset.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
asset = FileAsset.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
# Check if the asset is uploaded
if not asset.is_uploaded:
@@ -666,9 +634,7 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
# Check if the asset ids are provided
if not asset_ids:
return Response(
{"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST)
# get the asset id
assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug)
@@ -722,9 +688,7 @@ 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)
+7 -23
View File
@@ -72,11 +72,7 @@ class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePagi
response = super().handle_exception(exc)
return response
except Exception as e:
(
print(e, traceback.format_exc())
if settings.DEBUG
else print("Server Error")
)
(print(e, traceback.format_exc()) if settings.DEBUG else print("Server Error"))
if isinstance(e, IntegrityError):
return Response(
{"error": "The payload is not valid"},
@@ -115,9 +111,7 @@ class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePagi
if settings.DEBUG:
from django.db import connection
print(
f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}"
)
print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}")
return response
except Exception as exc:
@@ -139,16 +133,12 @@ class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePagi
@property
def fields(self):
fields = [
field for field in self.request.GET.get("fields", "").split(",") if field
]
fields = [field for field in self.request.GET.get("fields", "").split(",") if field]
return fields if fields else None
@property
def expand(self):
expand = [
expand for expand in self.request.GET.get("expand", "").split(",") if expand
]
expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand]
return expand if expand else None
@@ -216,9 +206,7 @@ class BaseAPIView(TimezoneMixin, ReadReplicaControlMixin, APIView, BasePaginator
if settings.DEBUG:
from django.db import connection
print(
f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}"
)
print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}")
return response
except Exception as exc:
@@ -235,14 +223,10 @@ class BaseAPIView(TimezoneMixin, ReadReplicaControlMixin, APIView, BasePaginator
@property
def fields(self):
fields = [
field for field in self.request.GET.get("fields", "").split(",") if field
]
fields = [field for field in self.request.GET.get("fields", "").split(",") if field]
return fields if fields else None
@property
def expand(self):
expand = [
expand for expand in self.request.GET.get("expand", "").split(",") if expand
]
expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand]
return expand if expand else None
+17 -60
View File
@@ -50,9 +50,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
issue_cycle__deleted_at__isnull=True,
)
.values("issue_cycle__cycle_id")
.annotate(
backlog_estimate_point=Sum(Cast("estimate_point__value", FloatField()))
)
.annotate(backlog_estimate_point=Sum(Cast("estimate_point__value", FloatField())))
.values("backlog_estimate_point")[:1]
)
unstarted_estimate_point = (
@@ -63,11 +61,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
issue_cycle__deleted_at__isnull=True,
)
.values("issue_cycle__cycle_id")
.annotate(
unstarted_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.annotate(unstarted_estimate_point=Sum(Cast("estimate_point__value", FloatField())))
.values("unstarted_estimate_point")[:1]
)
started_estimate_point = (
@@ -78,9 +72,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
issue_cycle__deleted_at__isnull=True,
)
.values("issue_cycle__cycle_id")
.annotate(
started_estimate_point=Sum(Cast("estimate_point__value", FloatField()))
)
.annotate(started_estimate_point=Sum(Cast("estimate_point__value", FloatField())))
.values("started_estimate_point")[:1]
)
cancelled_estimate_point = (
@@ -91,11 +83,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
issue_cycle__deleted_at__isnull=True,
)
.values("issue_cycle__cycle_id")
.annotate(
cancelled_estimate_point=Sum(
Cast("estimate_point__value", FloatField())
)
)
.annotate(cancelled_estimate_point=Sum(Cast("estimate_point__value", FloatField())))
.values("cancelled_estimate_point")[:1]
)
completed_estimate_point = (
@@ -106,11 +94,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
issue_cycle__deleted_at__isnull=True,
)
.values("issue_cycle__cycle_id")
.annotate(
completed_estimate_points=Sum(
Cast("estimate_point__value", FloatField())
)
)
.annotate(completed_estimate_points=Sum(Cast("estimate_point__value", FloatField())))
.values("completed_estimate_points")[:1]
)
total_estimate_point = (
@@ -120,9 +104,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
issue_cycle__deleted_at__isnull=True,
)
.values("issue_cycle__cycle_id")
.annotate(
total_estimate_points=Sum(Cast("estimate_point__value", FloatField()))
)
.annotate(total_estimate_points=Sum(Cast("estimate_point__value", FloatField())))
.values("total_estimate_points")[:1]
)
return (
@@ -138,9 +120,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
.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(
@@ -224,8 +204,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(start_date__gt=timezone.now(), then=Value("UPCOMING")),
@@ -279,9 +258,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
)
)
.annotate(
total_estimate_points=Coalesce(
Subquery(total_estimate_point), Value(0, output_field=FloatField())
)
total_estimate_points=Coalesce(Subquery(total_estimate_point), Value(0, output_field=FloatField()))
)
.order_by("-is_favorite", "name")
.distinct()
@@ -322,9 +299,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
).order_by("-is_favorite", "-created_at")
return Response(queryset, status=status.HTTP_200_OK)
else:
queryset = (
self.get_queryset().filter(archived_at__isnull=False).filter(pk=pk)
)
queryset = self.get_queryset().filter(archived_at__isnull=False).filter(pk=pk)
data = (
self.get_queryset()
.filter(pk=pk)
@@ -415,9 +390,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
)
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
@@ -452,9 +425,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
)
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
@@ -531,11 +502,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
"avatar_url",
"display_name",
)
.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",
@@ -571,11 +538,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
.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",
@@ -618,9 +581,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id, cycle_id):
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(
@@ -636,15 +597,11 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
project_id=project_id,
workspace__slug=slug,
).delete()
return Response(
{"archived_at": str(cycle.archived_at)}, status=status.HTTP_200_OK
)
return Response({"archived_at": str(cycle.archived_at)}, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def delete(self, request, slug, project_id, cycle_id):
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)

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