Compare commits

...

96 Commits

Author SHA1 Message Date
Anmol Singh Bhatia c05a5eb5ee [WEB-4981] fix: analytics portal modal #7858 2025-09-26 14:23:04 +05:30
Anmol Singh Bhatia b946b3a315 [WEB-4981] fix: analytics modal portal (#7852) 2025-09-25 20:26:44 +05:30
Vamsi Krishna 19e00b0d4b [WEB-5001]fix: prop name for sub work permissions #7854 2025-09-25 17:51:13 +05:30
Anmol Singh Bhatia 7f28cbebcf [WEB-4980] dev: propel modal portal component (#7851) 2025-09-25 15:56:33 +05:30
Vamsi Krishna 586a7a48ba [WEB-4896]feat: filters to project and workspace members list (#7786) 2025-09-24 17:45:43 +05:30
M. Palanikannan 85bffaa231 [WIKI-695] fix: tippy width fix #7843 2025-09-24 17:45:18 +05:30
Sangeetha b5ba0a705f [WEB-4996] fix: accessing NoneType intake #7847 2025-09-24 17:43:28 +05:30
Aaryan Khandelwal dce8b75a1e [WIKI-638] fix: peek overview closing while dropdowns are open (#7841) 2025-09-24 17:43:02 +05:30
Bavisetti Narayan 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 Shourya 47f68e3d3d [WEB-4979] fix: filters row missing for preset workspace views (#7836) 2025-09-22 19:29:41 +05:30
Vipin Chaudhary 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. Palanikannan 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 Shourya 9aef5d4aa9 [WEB-4951] [WEB-4884] feat: work item filters revamp (#7810) 2025-09-19 18:27:36 +05:30
Vamsi Krishna e6a7ca4c72 [WEB-4974] fix: table column spanning #7830 2025-09-19 15:58:01 +05:30
Vamsi Krishna a40c73cd22 [WEB-4431]fix: table column distribution #7819 2025-09-18 20:26:09 +05:30
Bavisetti Narayan 9ede04f1b3 [WEB-4851] chore: updated activity keys for work item (#7733) 2025-09-18 20:25:34 +05:30
Vamsi Krishna 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 Krishna f7d5ca4f83 [WEB-4953]fix: cycle progress percentage #7826 2025-09-18 20:21:56 +05:30
Vamsi Krishna 97059a2786 [WEB-4952]fix: published view work item description #7828 2025-09-18 20:21:31 +05:30
Vamsi Krishna af6ea40b84 [WEB-4826]chore: reduced spacing for sub-work items list and relations list #7814 2025-09-18 20:15:42 +05:30
Bavisetti Narayan 9182c9593b [WIKI-657] refactor: the page permissions in project (#7761) 2025-09-18 20:14:46 +05:30
Jayash Tripathy f59e557be1 [WEB-4879] refactor: organize stories and remove logs #7765 2025-09-18 20:13:54 +05:30
Sangeetha 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 Baheti d5c3c0cbe1 [WIKI-632] chore: add extended document editor props (#7783) 2025-09-18 20:11:13 +05:30
Nikhil f2057cd8fe [WEB-3528] fix: correct member id in modules list showing deleted_at members #7777 2025-09-18 20:10:04 +05:30
Nikhil 69c688b017 [WEB-4873]: Add webhook log cleanup task and update Celery schedule (#7772) 2025-09-18 20:09:01 +05:30
Prateek Shourya 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 Krishna 365d2d902c [WEB-4915]fix: redirection after onboarding completion #7824 2025-09-18 14:10:15 +05:30
Anmol Singh Bhatia 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
Vamsi Krishna 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
Nikhil 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
Nikhil 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 Shourya 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
Nikhil 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 Bhatia bf45635a7b [WEB-4898] fix: extended sidebar toggle #7797 2025-09-16 15:35:31 +05:30
Nikhil 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
Nikhil 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
Bavisetti Narayan 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
sriram veeraghanta 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 Tripathy 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 Ketireddy 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 Krishna 76a0b38dd1 [WEB-4890]fix: dropdown width #7778 2025-09-11 18:56:04 +05:30
Sangeetha 8ee665f491 [WEB-4875] fix: unsubscribed work items on workspace subscribed work item filter #7775 2025-09-11 17:52:51 +05:30
Vamsi Krishna 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 Krishna 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 Krishna 4cfea87108 [WEB-4857] fix: applied filters root update #7750 2025-09-11 14:21:30 +05:30
Vamsi Krishna 4fe2ef706b [WEB-4441]fix: members account type dropdown position #7759 2025-09-11 14:20:10 +05:30
sriram veeraghanta 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
Sangeetha 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
Lakhan Baheti 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 Tripathy 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 Krishna 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 veeraghanta 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 veeraghanta 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
Anmol Singh Bhatia 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
Anmol Singh Bhatia 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 Bhatia 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 Bhatia 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
Anmol Singh Bhatia 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 Bhatia 5a63e6dad2 [WEB-4858] chore: error page ui revamp #7747 2025-09-10 00:03:39 +05:30
Anmol Singh Bhatia 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 Tripathy 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 Bhatia 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 Bhatia 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
Anmol Singh Bhatia 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 Krishna 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 Chaudhary 34bdc2ad76 [WIKI-636] fix: replace project loader with page loader #7752 2025-09-09 19:27:00 +05:30
M. Palanikannan 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 Khandelwal 49127ebeea [WIKI-653] fix: page content loader #7753 2025-09-09 18:20:31 +05:30
Vamsi Krishna 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 Krishna 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 Bhatia 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 Krishna 37ce8a9fe6 [WEB-4800]chore: settings header component refactor #7669 2025-09-09 13:42:40 +05:30
Bavisetti Narayan 291101a8e5 chore: added attributes in ol tag (#7736) 2025-09-08 17:17:38 +05:30
Vipin Chaudhary 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 Bhatia 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
Jayash Tripathy 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 Tripathy 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 Chaudhary af1dcd335e [WIKI-640] chore: refactor link view #7727 2025-09-05 14:14:30 +05:30
Aaryan Khandelwal a1500c2206 [WIKI-644] fix: remove unnecessary props from the editor package #7726 2025-09-05 13:50:44 +05:30
Aaryan Khandelwal 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 Khandelwal b68d2ca921 [WIKI-642] fix: editor bubble menu overflow issues #7724 2025-09-04 18:16:03 +05:30
Aaryan Khandelwal 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 Bhatia 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 Jain 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 Narayan c40c7804e7 [WEB-4806] chore: updated html attribute validation #7719 2025-09-04 13:53:21 +05:30
Bavisetti Narayan 36c735bede [WEB-4806] chore: updated html attribute validation #7719 2025-09-04 13:52:43 +05:30
Bavisetti Narayan 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
730 changed files with 17443 additions and 7688 deletions
+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:
@@ -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";
+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 -2
View File
@@ -66,7 +66,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
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 (
@@ -230,7 +230,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
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 (
@@ -13,3 +13,4 @@ from .project import (
ProjectLitePermission,
)
from .base import allow_permission, ROLE
from .page import ProjectPagePermission
+20 -2
View File
@@ -39,13 +39,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
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
+125
View File
@@ -0,0 +1,125 @@
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,
+20 -3
View File
@@ -667,16 +667,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):
-26
View File
@@ -130,32 +130,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
@@ -15,7 +15,6 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_binary_data,
)
+11 -24
View File
@@ -4,22 +4,24 @@ 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>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/",
PageViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
@@ -27,45 +29,30 @@ urlpatterns = [
),
# 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
View File
@@ -165,8 +165,6 @@ from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint
from .page.base import (
PageViewSet,
PageFavoriteViewSet,
PageLogEndpoint,
SubPagesEndpoint,
PagesDescriptionViewSet,
PageDuplicateEndpoint,
)
+3 -13
View File
@@ -504,19 +504,6 @@ class CycleViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN], creator=True, model=Cycle)
def destroy(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
if cycle.owned_by_id != request.user.id and not (
ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or owner can delete the cycle"},
status=status.HTTP_403_FORBIDDEN,
)
cycle_issues = list(
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
@@ -1093,6 +1080,9 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
)
cycle_properties.filters = request.data.get("filters", cycle_properties.filters)
cycle_properties.rich_filters = request.data.get(
"rich_filters", cycle_properties.rich_filters
)
cycle_properties.display_filters = request.data.get(
"display_filters", cycle_properties.display_filters
)
+37 -19
View File
@@ -1,4 +1,5 @@
# Python imports
import copy
import json
# Django imports
@@ -28,11 +29,15 @@ from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from plane.app.permissions import allow_permission, ROLE
from plane.utils.host import base_host
from plane.utils.filters import ComplexFilterBackend
from plane.utils.filters import IssueFilterSet
class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer
model = CycleIssue
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
webhook_event = "cycle_issue"
bulk = True
@@ -65,24 +70,9 @@ class CycleIssueViewSet(BaseViewSet):
.distinct()
)
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def list(self, request, slug, project_id, cycle_id):
order_by_param = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET")
issue_queryset = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related(
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
)
.filter(**filters)
.annotate(
def apply_annotations(self, issues):
return (
issues.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
@@ -110,11 +100,36 @@ class CycleIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
)
)
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def list(self, request, slug, project_id, cycle_id):
filters = issue_filters(request.query_params, "GET")
issue_queryset = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
)
# Apply filtering from filterset
issue_queryset = self.filter_queryset(issue_queryset)
# Apply legacy filters
issue_queryset = issue_queryset.filter(**filters)
# Total count queryset
total_issue_queryset = copy.deepcopy(issue_queryset)
# Applying annotations to the issue queryset
issue_queryset = self.apply_annotations(issue_queryset)
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = issue_queryset.filter(**filters)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset, order_by_param=order_by_param
@@ -145,6 +160,7 @@ class CycleIssueViewSet(BaseViewSet):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -179,6 +195,7 @@ class CycleIssueViewSet(BaseViewSet):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -205,6 +222,7 @@ class CycleIssueViewSet(BaseViewSet):
order_by=order_by_param,
request=request,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
+29 -10
View File
@@ -28,6 +28,7 @@ from plane.db.models import (
ProjectMember,
CycleIssue,
IssueDescriptionVersion,
WorkspaceMember,
)
from plane.app.serializers import (
IssueCreateSerializer,
@@ -348,17 +349,32 @@ class IntakeIssueViewSet(BaseViewSet):
project_id=project_id,
intake_id=intake_id,
)
# Get the project member
project_member = ProjectMember.objects.get(
project_member = ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
is_active=True,
)
).first()
is_workspace_admin = WorkspaceMember.objects.filter(
workspace__slug=slug,
is_active=True,
member=request.user,
role=ROLE.ADMIN.value,
).exists()
if not project_member and not is_workspace_admin:
return Response(
{"error": "Only admin or creator can update the intake work items"},
status=status.HTTP_403_FORBIDDEN,
)
# 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 and project_member.role <= ROLE.GUEST.value)
and not is_workspace_admin
) and str(intake_issue.created_by_id) != str(request.user.id):
return Response(
{"error": "You cannot edit intake issues"},
status=status.HTTP_400_BAD_REQUEST,
@@ -391,8 +407,8 @@ class IntakeIssueViewSet(BaseViewSet):
Value([], output_field=ArrayField(UUIDField())),
),
).get(pk=intake_issue.issue_id, workspace__slug=slug, project_id=project_id)
# Only allow guests to edit name and description
if project_member.role <= 5:
if project_member and project_member.role <= ROLE.GUEST.value:
issue_data = {
"name": issue_data.get("name", issue.name),
"description_html": issue_data.get(
@@ -400,6 +416,7 @@ class IntakeIssueViewSet(BaseViewSet):
),
"description": issue_data.get("description", issue.description),
}
current_instance = json.dumps(
IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder
)
@@ -436,8 +453,10 @@ class IntakeIssueViewSet(BaseViewSet):
issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
# Only project admins and members can edit intake issue attributes
if project_member.role > 15:
# Only project admins can edit intake issue attributes
if (
project_member and project_member.role > ROLE.MEMBER.value
) or is_workspace_admin:
serializer = IntakeIssueSerializer(
intake_issue, data=request.data, partial=True
)
+30 -28
View File
@@ -1,4 +1,5 @@
# Python imports
import copy
import json
# Django imports
@@ -41,27 +42,20 @@ from plane.utils.host import base_host
# Module imports
from .. import BaseViewSet, BaseAPIView
from plane.utils.filters import ComplexFilterBackend
from plane.utils.filters import IssueFilterSet
class IssueArchiveViewSet(BaseViewSet):
serializer_class = IssueFlatSerializer
model = Issue
def get_queryset(self):
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
def apply_annotations(self, issues):
return (
Issue.objects.annotate(
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(deleted_at__isnull=True)
.filter(archived_at__isnull=False)
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
issues.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
@@ -95,6 +89,15 @@ class IssueArchiveViewSet(BaseViewSet):
.values("count")
)
)
.prefetch_related("assignees", "labels", "issue_module__module")
)
def get_queryset(self):
return (
Issue.objects.filter(Q(type__isnull=True) | Q(type__is_epic=False))
.filter(archived_at__isnull=False)
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
)
@method_decorator(gzip_page)
@@ -105,26 +108,25 @@ class IssueArchiveViewSet(BaseViewSet):
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters)
total_issue_queryset = Issue.objects.filter(
deleted_at__isnull=True,
archived_at__isnull=False,
project_id=project_id,
workspace__slug=slug,
).filter(**filters)
total_issue_queryset = (
total_issue_queryset
if show_sub_issues == "true"
else total_issue_queryset.filter(parent__isnull=True)
)
issue_queryset = self.get_queryset()
issue_queryset = (
issue_queryset
if show_sub_issues == "true"
else issue_queryset.filter(parent__isnull=True)
)
# Apply filtering from filterset
issue_queryset = self.filter_queryset(issue_queryset)
# Apply legacy filters
issue_queryset = issue_queryset.filter(**filters)
# Total count queryset
total_issue_queryset = copy.deepcopy(issue_queryset)
# Applying annotations to the issue queryset
issue_queryset = self.apply_annotations(issue_queryset)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset, order_by_param=order_by_param
+174 -113
View File
@@ -1,4 +1,5 @@
# Python imports
import copy
import json
# Django imports
@@ -6,16 +7,16 @@ from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
Count,
Exists,
F,
Func,
OuterRef,
Prefetch,
Q,
Subquery,
UUIDField,
Value,
Subquery,
Count,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
@@ -27,50 +28,55 @@ from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import ROLE, allow_permission
from plane.app.serializers import (
IssueCreateSerializer,
IssueDetailSerializer,
IssueUserPropertySerializer,
IssueSerializer,
IssueListDetailSerializer,
IssueSerializer,
IssueUserPropertySerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_description_version_task import issue_description_version_task
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.webhook_task import model_activity
from plane.db.models import (
Issue,
FileAsset,
IssueLink,
IssueUserProperty,
IssueReaction,
IssueSubscriber,
Project,
ProjectMember,
CycleIssue,
UserRecentVisit,
ModuleIssue,
IssueRelation,
FileAsset,
IntakeIssue,
Issue,
IssueAssignee,
IssueLabel,
IntakeIssue,
IssueLink,
IssueReaction,
IssueRelation,
IssueSubscriber,
IssueUserProperty,
ModuleIssue,
Project,
ProjectMember,
UserRecentVisit,
)
from plane.utils.filters import ComplexFilterBackend, IssueFilterSet
from plane.utils.global_paginator import paginate
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.host import base_host
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from .. import BaseAPIView, BaseViewSet
from plane.utils.timezone_converter import user_timezone_converter
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.global_paginator import paginate
from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.issue_description_version_task import issue_description_version_task
from plane.utils.host import base_host
from .. import BaseAPIView, BaseViewSet
class IssueListEndpoint(BaseAPIView):
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
issue_ids = request.GET.get("issues", False)
@@ -82,14 +88,27 @@ class IssueListEndpoint(BaseAPIView):
issue_ids = [issue_id for issue_id in issue_ids.split(",") if issue_id != ""]
queryset = (
Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
# Base queryset with basic filters
queryset = Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
)
# Apply filtering from filterset
queryset = self.filter_queryset(queryset)
# Apply legacy filters
filters = issue_filters(request.query_params, "GET")
issue_queryset = queryset.filter(**filters)
# Add select_related, prefetch_related if fields or expand is not None
if self.fields or self.expand:
issue_queryset = issue_queryset.select_related(
"workspace", "project", "state", "parent"
).prefetch_related("assignees", "labels", "issue_module__module")
# Add annotations
issue_queryset = (
issue_queryset.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
@@ -117,12 +136,10 @@ class IssueListEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
filters = issue_filters(request.query_params, "GET")
.distinct()
)
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = queryset.filter(**filters)
# Issue queryset
issue_queryset, _ = order_issue_queryset(
issue_queryset=issue_queryset, order_by_param=order_by_param
@@ -186,6 +203,12 @@ class IssueListEndpoint(BaseAPIView):
class IssueViewSet(BaseViewSet):
model = Issue
webhook_event = "issue"
search_fields = ["name"]
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
def get_serializer_class(self):
return (
IssueCreateSerializer
@@ -193,20 +216,17 @@ class IssueViewSet(BaseViewSet):
else IssueSerializer
)
model = Issue
webhook_event = "issue"
search_fields = ["name"]
filterset_fields = ["state__name", "assignees__id", "workspace__id"]
def get_queryset(self):
return (
Issue.issue_objects.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
issues = Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
).distinct()
return issues
def apply_annotations(self, issues):
issues = (
issues.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
@@ -242,6 +262,8 @@ class IssueViewSet(BaseViewSet):
)
)
return issues
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
@@ -250,15 +272,24 @@ class IssueViewSet(BaseViewSet):
extra_filters = {"updated_at__gt": request.GET.get("updated_at__gt")}
project = Project.objects.get(pk=project_id, workspace__slug=slug)
filters = issue_filters(request.query_params, "GET")
query_params = request.query_params.copy()
filters = issue_filters(query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters, **extra_filters)
# Custom ordering for priority and state
issue_queryset = self.get_queryset()
total_issue_queryset = Issue.issue_objects.filter(
project_id=project_id, workspace__slug=slug
).filter(**filters, **extra_filters)
# Apply rich filters
issue_queryset = self.filter_queryset(issue_queryset)
# Apply legacy filters
issue_queryset = issue_queryset.filter(**filters, **extra_filters)
# Keeping a copy of the queryset before applying annotations
filtered_issue_queryset = copy.deepcopy(issue_queryset)
# Applying annotations to the issue queryset
issue_queryset = self.apply_annotations(issue_queryset)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
@@ -292,14 +323,16 @@ class IssueViewSet(BaseViewSet):
and not project.guest_view_all_features
):
issue_queryset = issue_queryset.filter(created_by=request.user)
total_issue_queryset = total_issue_queryset.filter(created_by=request.user)
filtered_issue_queryset = filtered_issue_queryset.filter(
created_by=request.user
)
if group_by:
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
"error": "Group by and sub group by cannot have same parameters" # noqa: E501
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -308,7 +341,7 @@ class IssueViewSet(BaseViewSet):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
total_count_queryset=filtered_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -318,12 +351,14 @@ class IssueViewSet(BaseViewSet):
slug=slug,
project_id=project_id,
filters=filters,
queryset=filtered_issue_queryset,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
queryset=filtered_issue_queryset,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
@@ -342,7 +377,7 @@ class IssueViewSet(BaseViewSet):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
total_count_queryset=filtered_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -352,6 +387,7 @@ class IssueViewSet(BaseViewSet):
slug=slug,
project_id=project_id,
filters=filters,
queryset=filtered_issue_queryset,
),
group_by_field_name=group_by,
count_filter=Q(
@@ -368,7 +404,7 @@ class IssueViewSet(BaseViewSet):
order_by=order_by_param,
request=request,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
total_count_queryset=filtered_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -402,9 +438,11 @@ class IssueViewSet(BaseViewSet):
notification=True,
origin=base_host(request=request, is_app=True),
)
queryset = self.get_queryset()
queryset = self.apply_annotations(queryset)
issue = (
issue_queryset_grouper(
queryset=self.get_queryset().filter(pk=serializer.data["id"]),
queryset=queryset.filter(pk=serializer.data["id"]),
group_by=None,
sub_group_by=None,
)
@@ -609,9 +647,10 @@ class IssueViewSet(BaseViewSet):
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], creator=True, model=Issue
)
def partial_update(self, request, slug, project_id, pk=None):
queryset = self.get_queryset()
queryset = self.apply_annotations(queryset)
issue = (
self.get_queryset()
.annotate(
queryset.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
@@ -730,6 +769,9 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
user=request.user, project_id=project_id
)
issue_property.rich_filters = request.data.get(
"rich_filters", issue_property.rich_filters
)
issue_property.filters = request.data.get("filters", issue_property.filters)
issue_property.display_filters = request.data.get(
"display_filters", issue_property.display_filters
@@ -969,6 +1011,59 @@ class IssuePaginatedViewSet(BaseViewSet):
class IssueDetailEndpoint(BaseAPIView):
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
def apply_annotations(self, issues):
return (
issues.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
).values("cycle_id")[:1]
)
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_assignee",
queryset=IssueAssignee.objects.all(),
)
)
.prefetch_related(
Prefetch(
"label_issue",
queryset=IssueLabel.objects.all(),
)
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.all(),
)
)
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
@@ -1002,56 +1097,9 @@ class IssueDetailEndpoint(BaseAPIView):
.values("id")
)
# Main issue query
issue = (
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
.filter(Exists(permission_subquery))
.prefetch_related(
Prefetch(
"issue_assignee",
queryset=IssueAssignee.objects.all(),
)
)
.prefetch_related(
Prefetch(
"label_issue",
queryset=IssueLabel.objects.all(),
)
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.all(),
)
)
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
).values("cycle_id")[:1]
)
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
issue = Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id
).filter(Exists(permission_subquery))
# Add additional prefetch based on expand parameter
if self.expand:
@@ -1070,8 +1118,20 @@ class IssueDetailEndpoint(BaseAPIView):
)
)
# Apply filtering from filterset
issue = self.filter_queryset(issue)
# Apply legacy filters
issue = issue.filter(**filters)
# Total count queryset
total_issue_queryset = copy.deepcopy(issue)
# Applying annotations to the issue queryset
issue = self.apply_annotations(issue)
order_by_param = request.GET.get("order_by", "-created_at")
# Issue queryset
issue, order_by_param = order_issue_queryset(
issue_queryset=issue, order_by_param=order_by_param
@@ -1079,7 +1139,8 @@ class IssueDetailEndpoint(BaseAPIView):
return self.paginate(
request=request,
order_by=order_by_param,
queryset=(issue),
queryset=issue,
total_count_queryset=total_issue_queryset,
on_results=lambda issue: IssueListDetailSerializer(
issue, many=True, fields=self.fields, expand=self.expand
).data,
+7 -4
View File
@@ -232,9 +232,6 @@ class ModuleViewSet(BaseViewSet):
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.annotate(is_favorite=Exists(favorite_subquery))
.select_related("project")
.select_related("workspace")
.select_related("lead")
.prefetch_related("members")
.prefetch_related(
Prefetch(
@@ -315,7 +312,10 @@ class ModuleViewSet(BaseViewSet):
ArrayAgg(
"members__id",
distinct=True,
filter=~Q(members__id__isnull=True),
filter=Q(
members__id__isnull=False,
modulemember__deleted_at__isnull=True,
),
),
Value([], output_field=ArrayField(UUIDField())),
)
@@ -904,6 +904,9 @@ class ModuleUserPropertiesEndpoint(BaseAPIView):
module_properties.filters = request.data.get(
"filters", module_properties.filters
)
module_properties.rich_filters = request.data.get(
"rich_filters", module_properties.rich_filters
)
module_properties.display_filters = request.data.get(
"display_filters", module_properties.display_filters
)
+43 -18
View File
@@ -1,4 +1,5 @@
# Python imports
import copy
import json
from django.db.models import F, Func, OuterRef, Q, Subquery
@@ -31,8 +32,8 @@ from plane.utils.grouper import (
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
# Module imports
from plane.utils.filters import ComplexFilterBackend
from plane.utils.filters import IssueFilterSet
from .. import BaseViewSet
from plane.utils.host import base_host
@@ -42,20 +43,12 @@ class ModuleIssueViewSet(BaseViewSet):
model = ModuleIssue
webhook_event = "module_issue"
bulk = True
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
filterset_fields = ["issue__labels__id", "issue__assignees__id"]
def get_queryset(self):
def apply_annotations(self, issues):
return (
Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_module__module_id=self.kwargs.get("module_id"),
issue_module__deleted_at__isnull=True,
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
issues.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
@@ -83,13 +76,37 @@ class ModuleIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related("assignees", "labels", "issue_module__module")
)
def get_queryset(self):
return (
Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_module__module_id=self.kwargs.get("module_id"),
issue_module__deleted_at__isnull=True,
)
).distinct()
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def list(self, request, slug, project_id, module_id):
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters)
issue_queryset = self.get_queryset()
# Apply filtering from filterset
issue_queryset = self.filter_queryset(issue_queryset)
# Apply legacy filters
issue_queryset = issue_queryset.filter(**filters)
# Total count queryset
total_issue_queryset = copy.deepcopy(issue_queryset)
# Apply annotations to the issue queryset
issue_queryset = self.apply_annotations(issue_queryset)
order_by_param = request.GET.get("order_by", "created_at")
# Issue queryset
@@ -122,6 +139,7 @@ class ModuleIssueViewSet(BaseViewSet):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -131,12 +149,14 @@ class ModuleIssueViewSet(BaseViewSet):
slug=slug,
project_id=project_id,
filters=filters,
queryset=total_issue_queryset,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
queryset=total_issue_queryset,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
@@ -156,6 +176,7 @@ class ModuleIssueViewSet(BaseViewSet):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -165,6 +186,7 @@ class ModuleIssueViewSet(BaseViewSet):
slug=slug,
project_id=project_id,
filters=filters,
queryset=total_issue_queryset,
),
group_by_field_name=group_by,
count_filter=Q(
@@ -182,6 +204,7 @@ class ModuleIssueViewSet(BaseViewSet):
order_by=order_by_param,
request=request,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -282,9 +305,11 @@ class ModuleIssueViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=json.dumps(
{
"module_name": module_issue.first().module.name
if (module_issue.first() and module_issue.first().module)
else None
"module_name": (
module_issue.first().module.name
if (module_issue.first() and module_issue.first().module)
else None
)
}
),
epoch=int(timezone.now().timestamp()),
+120 -108
View File
@@ -6,9 +6,17 @@ from django.core.serializers.json import DjangoJSONEncoder
# Django imports
from django.db import connection
from django.db.models import Exists, OuterRef, Q, Value, UUIDField
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db.models import (
Exists,
OuterRef,
Q,
Value,
UUIDField,
Count,
Case,
When,
IntegerField,
)
from django.http import StreamingHttpResponse
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
@@ -21,9 +29,7 @@ from rest_framework.response import Response
# Module imports
from plane.app.permissions import allow_permission, ROLE
from plane.app.serializers import (
PageLogSerializer,
PageSerializer,
SubPageSerializer,
PageDetailSerializer,
PageBinaryUpdateSerializer,
)
@@ -37,11 +43,14 @@ from plane.db.models import (
UserRecentVisit,
)
from plane.utils.error_codes import ERROR_CODES
# Local imports
from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
from plane.bgtasks.page_version_task import page_version
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets
from plane.app.permissions import ProjectPagePermission
def unarchive_archive_page_and_descendants(page_id, archived_at):
@@ -63,6 +72,7 @@ def unarchive_archive_page_and_descendants(page_id, archived_at):
class PageViewSet(BaseViewSet):
serializer_class = PageSerializer
model = Page
permission_classes = [ProjectPagePermission]
search_fields = ["name"]
def get_queryset(self):
@@ -117,7 +127,6 @@ class PageViewSet(BaseViewSet):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
serializer = PageSerializer(
data=request.data,
@@ -139,11 +148,10 @@ class PageViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
def partial_update(self, request, slug, project_id, page_id):
try:
page = Page.objects.get(
pk=pk, workspace__slug=slug, projects__id=project_id
pk=page_id, workspace__slug=slug, projects__id=project_id
)
if page.is_locked:
@@ -181,7 +189,7 @@ class PageViewSet(BaseViewSet):
{"description_html": page_description},
cls=DjangoJSONEncoder,
),
page_id=pk,
page_id=page_id,
)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -194,9 +202,8 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve(self, request, slug, project_id, pk=None):
page = self.get_queryset().filter(pk=pk).first()
def retrieve(self, request, slug, project_id, page_id=None):
page = self.get_queryset().filter(pk=page_id).first()
project = Project.objects.get(pk=project_id)
track_visit = request.query_params.get("track_visit", "true").lower() == "true"
@@ -227,7 +234,7 @@ class PageViewSet(BaseViewSet):
)
else:
issue_ids = PageLog.objects.filter(
page_id=pk, entity_name="issue"
page_id=page_id, entity_name="issue"
).values_list("entity_identifier", flat=True)
data = PageDetailSerializer(page).data
data["issue_ids"] = issue_ids
@@ -235,26 +242,24 @@ class PageViewSet(BaseViewSet):
recent_visited_task.delay(
slug=slug,
entity_name="page",
entity_identifier=pk,
entity_identifier=page_id,
user_id=request.user.id,
project_id=project_id,
)
return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def lock(self, request, slug, project_id, pk):
def lock(self, request, slug, project_id, page_id):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
pk=page_id, workspace__slug=slug, projects__id=project_id
).first()
page.is_locked = True
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def unlock(self, request, slug, project_id, pk):
def unlock(self, request, slug, project_id, page_id):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
pk=page_id, workspace__slug=slug, projects__id=project_id
).first()
page.is_locked = False
@@ -262,11 +267,10 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def access(self, request, slug, project_id, pk):
def access(self, request, slug, project_id, page_id):
access = request.data.get("access", 0)
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
pk=page_id, workspace__slug=slug, projects__id=project_id
).first()
# Only update access if the page owner is the requesting user
@@ -285,7 +289,6 @@ class PageViewSet(BaseViewSet):
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
queryset = self.get_queryset()
project = Project.objects.get(pk=project_id)
@@ -303,9 +306,10 @@ class PageViewSet(BaseViewSet):
pages = PageSerializer(queryset, many=True).data
return Response(pages, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def archive(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
def archive(self, request, slug, project_id, page_id):
page = Page.objects.get(
pk=page_id, workspace__slug=slug, projects__id=project_id
)
# only the owner or admin can archive the page
if (
@@ -321,18 +325,19 @@ class PageViewSet(BaseViewSet):
UserFavorite.objects.filter(
entity_type="page",
entity_identifier=pk,
entity_identifier=page_id,
project_id=project_id,
workspace__slug=slug,
).delete()
unarchive_archive_page_and_descendants(pk, datetime.now())
unarchive_archive_page_and_descendants(page_id, datetime.now())
return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def unarchive(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
def unarchive(self, request, slug, project_id, page_id):
page = Page.objects.get(
pk=page_id, workspace__slug=slug, projects__id=project_id
)
# only the owner or admin can un archive the page
if (
@@ -346,18 +351,19 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
# if parent page is archived then the page will be un archived breaking the hierarchy
# if parent archived then page will be un archived breaking hierarchy
if page.parent_id and page.parent.archived_at:
page.parent = None
page.save(update_fields=["parent"])
unarchive_archive_page_and_descendants(pk, None)
unarchive_archive_page_and_descendants(page_id, None)
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def destroy(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
def destroy(self, request, slug, project_id, page_id):
page = Page.objects.get(
pk=page_id, workspace__slug=slug, projects__id=project_id
)
if page.archived_at is None:
return Response(
@@ -381,7 +387,7 @@ class PageViewSet(BaseViewSet):
# remove parent from all the children
_ = Page.objects.filter(
parent_id=pk, projects__id=project_id, workspace__slug=slug
parent_id=page_id, projects__id=project_id, workspace__slug=slug
).update(parent=None)
page.delete()
@@ -389,105 +395,109 @@ class PageViewSet(BaseViewSet):
UserFavorite.objects.filter(
project=project_id,
workspace__slug=slug,
entity_identifier=pk,
entity_identifier=page_id,
entity_type="page",
).delete()
# Delete the page from recent visit
UserRecentVisit.objects.filter(
project_id=project_id,
workspace__slug=slug,
entity_identifier=pk,
entity_identifier=page_id,
entity_name="page",
).delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)
def summary(self, request, slug, project_id):
queryset = (
Page.objects.filter(workspace__slug=slug)
.filter(
projects__project_projectmember__member=self.request.user,
projects__project_projectmember__is_active=True,
projects__archived_at__isnull=True,
)
.filter(parent__isnull=True)
.filter(Q(owned_by=request.user) | Q(access=0))
.annotate(
project=Exists(
ProjectPage.objects.filter(
page_id=OuterRef("id"), project_id=self.kwargs.get("project_id")
)
)
)
.filter(project=True)
.distinct()
)
project = Project.objects.get(pk=project_id)
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=ROLE.GUEST.value,
is_active=True,
).exists()
and not project.guest_view_all_features
):
queryset = queryset.filter(owned_by=request.user)
stats = queryset.aggregate(
public_pages=Count(
Case(
When(access=Page.PUBLIC_ACCESS, archived_at__isnull=True, then=1),
output_field=IntegerField(),
)
),
private_pages=Count(
Case(
When(access=Page.PRIVATE_ACCESS, archived_at__isnull=True, then=1),
output_field=IntegerField(),
)
),
archived_pages=Count(
Case(
When(archived_at__isnull=False, then=1), output_field=IntegerField()
)
),
)
return Response(stats, status=status.HTTP_200_OK)
class PageFavoriteViewSet(BaseViewSet):
model = UserFavorite
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, pk):
def create(self, request, slug, project_id, page_id):
_ = UserFavorite.objects.create(
project_id=project_id,
entity_identifier=pk,
entity_identifier=page_id,
entity_type="page",
user=request.user,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, pk):
def destroy(self, request, slug, project_id, page_id):
page_favorite = UserFavorite.objects.get(
project=project_id,
user=request.user,
workspace__slug=slug,
entity_identifier=pk,
entity_identifier=page_id,
entity_type="page",
)
page_favorite.delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)
class PageLogEndpoint(BaseAPIView):
serializer_class = PageLogSerializer
model = PageLog
def post(self, request, slug, project_id, page_id):
serializer = PageLogSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id, page_id=page_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def patch(self, request, slug, project_id, page_id, transaction):
page_transaction = PageLog.objects.get(
workspace__slug=slug,
project_id=project_id,
page_id=page_id,
transaction=transaction,
)
serializer = PageLogSerializer(
page_transaction, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, project_id, page_id, transaction):
transaction = PageLog.objects.get(
workspace__slug=slug,
project_id=project_id,
page_id=page_id,
transaction=transaction,
)
# Delete the transaction object
transaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class SubPagesEndpoint(BaseAPIView):
@method_decorator(gzip_page)
def get(self, request, slug, project_id, page_id):
pages = (
PageLog.objects.filter(
page_id=page_id,
workspace__slug=slug,
entity_name__in=["forward_link", "back_link"],
)
.select_related("project")
.select_related("workspace")
)
return Response(
SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK
)
class PagesDescriptionViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve(self, request, slug, project_id, pk):
permission_classes = [ProjectPagePermission]
def retrieve(self, request, slug, project_id, page_id):
page = (
Page.objects.filter(pk=pk, workspace__slug=slug, projects__id=project_id)
Page.objects.filter(
pk=page_id, workspace__slug=slug, projects__id=project_id
)
.filter(Q(owned_by=self.request.user) | Q(access=0))
.first()
)
@@ -507,10 +517,11 @@ class PagesDescriptionViewSet(BaseViewSet):
response["Content-Disposition"] = 'attachment; filename="page_description.bin"'
return response
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk):
def partial_update(self, request, slug, project_id, page_id):
page = (
Page.objects.filter(pk=pk, workspace__slug=slug, projects__id=project_id)
Page.objects.filter(
pk=page_id, workspace__slug=slug, projects__id=project_id
)
.filter(Q(owned_by=self.request.user) | Q(access=0))
.first()
)
@@ -547,7 +558,7 @@ class PagesDescriptionViewSet(BaseViewSet):
# Capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data, old_value=existing_instance, page_id=pk
new_value=request.data, old_value=existing_instance, page_id=page_id
)
# Update the page using serializer
@@ -565,7 +576,8 @@ class PagesDescriptionViewSet(BaseViewSet):
class PageDuplicateEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
permission_classes = [ProjectPagePermission]
def post(self, request, slug, project_id, page_id):
page = Page.objects.filter(
pk=page_id, workspace__slug=slug, projects__id=project_id
+4 -2
View File
@@ -7,10 +7,12 @@ from plane.db.models import PageVersion
from ..base import BaseAPIView
from plane.app.serializers import PageVersionSerializer, PageVersionDetailSerializer
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import ProjectPagePermission
class PageVersionEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
permission_classes = [ProjectPagePermission]
def get(self, request, slug, project_id, page_id, pk=None):
# Check if pk is provided
if pk:
+39 -13
View File
@@ -5,13 +5,12 @@ from django.utils import timezone
import json
# Django imports
from django.db import IntegrityError
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from rest_framework.response import Response
from rest_framework import serializers, status
from rest_framework import status
from rest_framework.permissions import AllowAny
# Module imports
@@ -106,7 +105,10 @@ class ProjectViewSet(BaseViewSet):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
projects = self.get_queryset().order_by("sort_order", "name")
if WorkspaceMember.objects.filter(
member=request.user, workspace__slug=slug, is_active=True, role=5
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.GUEST.value,
).exists():
projects = projects.filter(
project_projectmember__member=self.request.user,
@@ -114,7 +116,10 @@ class ProjectViewSet(BaseViewSet):
)
if WorkspaceMember.objects.filter(
member=request.user, workspace__slug=slug, is_active=True, role=15
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.MEMBER.value,
).exists():
projects = projects.filter(
Q(
@@ -189,7 +194,10 @@ class ProjectViewSet(BaseViewSet):
)
if WorkspaceMember.objects.filter(
member=request.user, workspace__slug=slug, is_active=True, role=5
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.GUEST.value,
).exists():
projects = projects.filter(
project_projectmember__member=self.request.user,
@@ -197,7 +205,10 @@ class ProjectViewSet(BaseViewSet):
)
if WorkspaceMember.objects.filter(
member=request.user, workspace__slug=slug, is_active=True, role=15
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.MEMBER.value,
).exists():
projects = projects.filter(
Q(
@@ -250,7 +261,9 @@ class ProjectViewSet(BaseViewSet):
# Add the user as Administrator to the project
_ = ProjectMember.objects.create(
project_id=serializer.data["id"], member=request.user, role=20
project_id=serializer.data["id"],
member=request.user,
role=ROLE.ADMIN.value,
)
# Also create the issue property for the user
_ = IssueUserProperty.objects.create(
@@ -263,7 +276,7 @@ class ProjectViewSet(BaseViewSet):
ProjectMember.objects.create(
project_id=serializer.data["id"],
member_id=serializer.data["project_lead"],
role=20,
role=ROLE.ADMIN.value,
)
# Also create the issue property for the user
IssueUserProperty.objects.create(
@@ -341,13 +354,23 @@ class ProjectViewSet(BaseViewSet):
def partial_update(self, request, slug, pk=None):
# try:
if not ProjectMember.objects.filter(
is_workspace_admin = WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.ADMIN.value,
).exists()
is_project_admin = ProjectMember.objects.filter(
member=request.user,
workspace__slug=slug,
project_id=pk,
role=20,
role=ROLE.ADMIN.value,
is_active=True,
).exists():
).exists()
# Return error for if the user is neither workspace admin nor project admin
if not is_project_admin and not is_workspace_admin:
return Response(
{"error": "You don't have the required permissions."},
status=status.HTTP_403_FORBIDDEN,
@@ -402,13 +425,16 @@ class ProjectViewSet(BaseViewSet):
def destroy(self, request, slug, pk):
if (
WorkspaceMember.objects.filter(
member=request.user, workspace__slug=slug, is_active=True, role=20
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.ADMIN.value,
).exists()
or ProjectMember.objects.filter(
member=request.user,
workspace__slug=slug,
project_id=pk,
role=20,
role=ROLE.ADMIN.value,
is_active=True,
).exists()
):
+46 -41
View File
@@ -1,3 +1,5 @@
import copy
# Django imports
from django.db.models import (
Exists,
@@ -39,6 +41,8 @@ from plane.utils.order_queryset import order_issue_queryset
from plane.bgtasks.recent_visited_task import recent_visited_task
from .. import BaseViewSet
from plane.db.models import UserFavorite
from plane.utils.filters import ComplexFilterBackend
from plane.utils.filters import IssueFilterSet
class WorkspaceViewViewSet(BaseViewSet):
@@ -56,7 +60,6 @@ class WorkspaceViewViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project__isnull=True)
.filter(Q(owned_by=self.request.user) | Q(access=1))
.select_related("workspace")
.order_by(self.request.GET.get("order_by", "-created_at"))
.distinct()
)
@@ -145,6 +148,9 @@ class WorkspaceViewViewSet(BaseViewSet):
class WorkspaceViewIssuesViewSet(BaseViewSet):
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
def _get_project_permission_filters(self):
"""
Get common project permission filters for guest users and role-based access control.
@@ -167,35 +173,9 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
project__project_projectmember__is_active=True,
)
def get_queryset(self):
def apply_annotations(self, issues):
return (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("state")
.prefetch_related(
Prefetch(
"issue_assignee",
queryset=IssueAssignee.objects.all(),
)
)
.prefetch_related(
Prefetch(
"label_issue",
queryset=IssueLabel.objects.all(),
)
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.all(),
)
)
.annotate(
issues.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
@@ -223,32 +203,57 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_assignee",
queryset=IssueAssignee.objects.all(),
)
)
.prefetch_related(
Prefetch(
"label_issue",
queryset=IssueLabel.objects.all(),
)
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.all(),
)
)
)
def get_queryset(self):
return Issue.issue_objects.filter(workspace__slug=self.kwargs.get("slug"))
@method_decorator(gzip_page)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def list(self, request, slug):
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset()
# Apply filtering from filterset
issue_queryset = self.filter_queryset(issue_queryset)
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters)
# Apply legacy filters
filters = issue_filters(request.query_params, "GET")
issue_queryset = issue_queryset.filter(**filters)
# Get common project permission filters
permission_filters = self._get_project_permission_filters()
# Base query for the counts
total_issue_count = (
Issue.issue_objects.filter(**filters)
.filter(workspace__slug=slug)
.filter(permission_filters)
.only("id")
)
# Apply project permission filters to the issue queryset
issue_queryset = issue_queryset.filter(permission_filters)
# Base query for the counts
total_issue_count_queryset = copy.deepcopy(issue_queryset)
total_issue_count_queryset = total_issue_count_queryset.only("id")
# Apply annotations to the issue queryset
issue_queryset = self.apply_annotations(issue_queryset)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset, order_by_param=order_by_param
@@ -260,7 +265,7 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
request=request,
queryset=issue_queryset,
on_results=lambda issues: ViewIssueListSerializer(issues, many=True).data,
total_count_queryset=total_issue_count,
total_count_queryset=total_issue_count_queryset,
)
+57 -22
View File
@@ -1,4 +1,5 @@
# Python imports
import copy
from datetime import date
from dateutil.relativedelta import relativedelta
@@ -56,6 +57,8 @@ from plane.utils.grouper import (
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from plane.utils.filters import ComplexFilterBackend
from plane.utils.filters import IssueFilterSet
class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
@@ -91,23 +94,12 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
permission_classes = [WorkspaceViewerPermission]
def get(self, request, slug, user_id):
filters = issue_filters(request.query_params, "GET")
filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.filter(
Q(assignees__in=[user_id])
| Q(created_by_id=user_id)
| Q(issue_subscribers__subscriber_id=user_id),
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
def apply_annotations(self, issues):
return (
issues.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
@@ -135,8 +127,36 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.order_by("created_at")
).distinct()
.prefetch_related("assignees", "labels", "issue_module__module")
)
def get(self, request, slug, user_id):
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = Issue.issue_objects.filter(
id__in=Issue.issue_objects.filter(
Q(assignees__in=[user_id])
| Q(created_by_id=user_id)
| Q(issue_subscribers__subscriber_id=user_id),
workspace__slug=slug,
).values_list("id", flat=True),
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
# Apply filtering from filterset
issue_queryset = self.filter_queryset(issue_queryset)
# Apply legacy filters
issue_queryset = issue_queryset.filter(**filters)
# Total count queryset
total_issue_queryset = copy.deepcopy(issue_queryset)
# Apply annotations to the issue queryset
issue_queryset = self.apply_annotations(issue_queryset)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
@@ -157,7 +177,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
"error": "Group by and sub group by cannot have same parameters" # noqa: E501
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -166,15 +186,22 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by, slug=slug, filters=filters
field=group_by,
slug=slug,
filters=filters,
queryset=total_issue_queryset,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by, slug=slug, filters=filters
field=sub_group_by,
slug=slug,
filters=filters,
queryset=total_issue_queryset,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
@@ -193,12 +220,16 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by, slug=slug, filters=filters
field=group_by,
slug=slug,
filters=filters,
queryset=total_issue_queryset,
),
group_by_field_name=group_by,
count_filter=Q(
@@ -215,6 +246,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
order_by=order_by_param,
request=request,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -232,6 +264,9 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView):
workspace_properties.filters = request.data.get(
"filters", workspace_properties.filters
)
workspace_properties.rich_filters = request.data.get(
"rich_filters", workspace_properties.rich_filters
)
workspace_properties.display_filters = request.data.get(
"display_filters", workspace_properties.display_filters
)
@@ -1,6 +1,3 @@
# Python imports
from urllib.parse import urlencode, urljoin
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
@@ -19,7 +16,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class SignInAuthEndpoint(View):
@@ -34,11 +31,11 @@ class SignInAuthEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
# Base URL join
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -58,10 +55,10 @@ class SignInAuthEndpoint(View):
)
params = exc.get_error_dict()
# Next path
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -76,10 +73,10 @@ class SignInAuthEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -92,10 +89,10 @@ class SignInAuthEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -112,19 +109,23 @@ class SignInAuthEndpoint(View):
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = str(validate_next_path(next_path))
path = next_path
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host(request=request, is_app=True), path)
# Get the safe redirect URL
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={},
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -141,10 +142,10 @@ class SignUpAuthEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -161,10 +162,10 @@ class SignUpAuthEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
# Validate the email
@@ -179,10 +180,10 @@ class SignUpAuthEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -197,10 +198,10 @@ class SignUpAuthEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -217,17 +218,21 @@ class SignUpAuthEndpoint(View):
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = str(validate_next_path(next_path))
path = next_path
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host(request=request, is_app=True), path)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={},
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -1,5 +1,5 @@
# Python imports
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.http import HttpResponseRedirect
@@ -16,8 +16,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class GitHubOauthInitiateEndpoint(View):
def get(self, request):
@@ -35,10 +34,10 @@ class GitHubOauthInitiateEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -49,10 +48,10 @@ class GitHubOauthInitiateEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -61,7 +60,6 @@ class GitHubCallbackEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
base_host = request.session.get("host")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
@@ -70,9 +68,11 @@ class GitHubCallbackEndpoint(View):
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
if not code:
@@ -81,9 +81,11 @@ class GitHubCallbackEndpoint(View):
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -93,17 +95,23 @@ class GitHubCallbackEndpoint(View):
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = str(validate_next_path(next_path))
path = next_path
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host, path)
# Get the safe redirect URL
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={}
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -1,5 +1,5 @@
# Python imports
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.http import HttpResponseRedirect
@@ -16,7 +16,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class GitLabOauthInitiateEndpoint(View):
@@ -25,7 +25,7 @@ class GitLabOauthInitiateEndpoint(View):
request.session["host"] = base_host(request=request, is_app=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(validate_next_path(next_path))
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
@@ -35,10 +35,10 @@ class GitLabOauthInitiateEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -49,10 +49,10 @@ class GitLabOauthInitiateEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -61,7 +61,6 @@ class GitLabCallbackEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
base_host = request.session.get("host")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
@@ -70,9 +69,11 @@ class GitLabCallbackEndpoint(View):
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
if not code:
@@ -81,9 +82,11 @@ class GitLabCallbackEndpoint(View):
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -94,16 +97,23 @@ class GitLabCallbackEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = str(validate_next_path(next_path))
path = next_path
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host, path)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={}
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -1,6 +1,5 @@
# Python imports
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.http import HttpResponseRedirect
@@ -18,7 +17,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class GoogleOauthInitiateEndpoint(View):
@@ -36,10 +35,10 @@ class GoogleOauthInitiateEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -51,10 +50,10 @@ class GoogleOauthInitiateEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -63,7 +62,6 @@ class GoogleCallbackEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
base_host = request.session.get("host")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
@@ -72,9 +70,11 @@ class GoogleCallbackEndpoint(View):
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
@@ -82,9 +82,11 @@ class GoogleCallbackEndpoint(View):
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
provider = GoogleOAuthProvider(
@@ -94,15 +96,21 @@ class GoogleCallbackEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
# Get the redirection path
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(
base_host, str(validate_next_path(next_path)) if next_path else path
if next_path:
path = next_path
else:
path = get_redirection_path(user=user)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={}
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -1,6 +1,3 @@
# Python imports
from urllib.parse import urlencode, urljoin
# Django imports
from django.core.validators import validate_email
from django.http import HttpResponseRedirect
@@ -26,7 +23,7 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
)
from plane.authentication.rate_limit import AuthenticationThrottle
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class MagicGenerateEndpoint(APIView):
@@ -72,10 +69,10 @@ class MagicSignInEndpoint(View):
error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -88,10 +85,10 @@ class MagicSignInEndpoint(View):
error_message="USER_DOES_NOT_EXIST",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -107,7 +104,8 @@ class MagicSignInEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_app=True)
if user.is_password_autoset and profile.is_onboarded:
path = "accounts/set-password"
# Redirect to the home page
path = "/"
else:
# Get the redirection path
path = (
@@ -116,15 +114,19 @@ class MagicSignInEndpoint(View):
else str(get_redirection_path(user=user))
)
# redirect to referer path
url = urljoin(base_host(request=request, is_app=True), path)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={},
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -144,10 +146,10 @@ class MagicSignUpEndpoint(View):
error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
# Existing user
@@ -158,10 +160,10 @@ class MagicSignUpEndpoint(View):
error_message="USER_ALREADY_EXIST",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -177,18 +179,22 @@ class MagicSignUpEndpoint(View):
user_login(request=request, user=user, is_app=True)
# Get the redirection path
if next_path:
path = str(validate_next_path(next_path))
path = next_path
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host(request=request, is_app=True), path)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=path,
params={},
)
return HttpResponseRedirect(url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(
base_host(request=request, is_app=True), "?" + urlencode(params)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -1,11 +1,9 @@
# Python imports
from urllib.parse import urlencode
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.http import HttpResponseRedirect
from django.views import View
from django.utils.http import url_has_allowed_host_and_scheme
# Module imports
from plane.authentication.provider.credentials.email import EmailProvider
@@ -17,7 +15,7 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
class SignInAuthSpaceEndpoint(View):
@@ -32,9 +30,11 @@ class SignInAuthSpaceEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
# set the referer as session to redirect after login
@@ -51,9 +51,11 @@ class SignInAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
# Validate email
@@ -67,9 +69,11 @@ class SignInAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
# Existing User
@@ -82,9 +86,11 @@ class SignInAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -94,14 +100,20 @@ class SignInAuthSpaceEndpoint(View):
user = provider.authenticate()
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# redirect to next path
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
# redirect to referer path
next_path = validate_next_path(next_path=next_path)
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(base_host(request=request, is_space=True))
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -117,9 +129,11 @@ class SignUpAuthSpaceEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
email = request.POST.get("email", False)
@@ -135,9 +149,11 @@ class SignUpAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
# Validate the email
email = email.strip().lower()
@@ -151,9 +167,11 @@ class SignUpAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
# Existing User
@@ -166,9 +184,11 @@ class SignUpAuthSpaceEndpoint(View):
payload={"email": str(email)},
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -179,11 +199,17 @@ class SignUpAuthSpaceEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# redirect to referer path
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
next_path = validate_next_path(next_path=next_path)
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(base_host(request=request, is_space=True))
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -1,10 +1,10 @@
# Python imports
import uuid
from urllib.parse import urlencode
# Django import
from django.http import HttpResponseRedirect
from django.views import View
from django.utils.http import url_has_allowed_host_and_scheme
# Module imports
from plane.authentication.provider.oauth.github import GitHubOAuthProvider
@@ -15,7 +15,7 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
class GitHubOauthInitiateSpaceEndpoint(View):
@@ -23,9 +23,6 @@ class GitHubOauthInitiateSpaceEndpoint(View):
# Get host and next path
request.session["host"] = base_host(request=request, is_space=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
@@ -34,9 +31,11 @@ class GitHubOauthInitiateSpaceEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -47,9 +46,11 @@ class GitHubOauthInitiateSpaceEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -66,9 +67,11 @@ class GitHubCallbackSpaceEndpoint(View):
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
if not code:
@@ -77,9 +80,11 @@ class GitHubCallbackSpaceEndpoint(View):
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -89,11 +94,18 @@ class GitHubCallbackSpaceEndpoint(View):
user_login(request=request, user=user, is_space=True)
# Process workspace and project invitations
# redirect to referer path
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
next_path = validate_next_path(next_path=next_path)
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(base_host(request=request, is_space=True))
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -1,10 +1,10 @@
# Python imports
import uuid
from urllib.parse import urlencode
# Django import
from django.http import HttpResponseRedirect
from django.views import View
from django.utils.http import url_has_allowed_host_and_scheme
# Module imports
from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider
@@ -15,7 +15,8 @@ from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
class GitLabOauthInitiateSpaceEndpoint(View):
@@ -23,8 +24,6 @@ class GitLabOauthInitiateSpaceEndpoint(View):
# Get host and next path
request.session["host"] = base_host(request=request, is_space=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
@@ -34,9 +33,11 @@ class GitLabOauthInitiateSpaceEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -47,9 +48,11 @@ class GitLabOauthInitiateSpaceEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -66,9 +69,11 @@ class GitLabCallbackSpaceEndpoint(View):
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
if not code:
@@ -77,9 +82,11 @@ class GitLabCallbackSpaceEndpoint(View):
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -89,11 +96,18 @@ class GitLabCallbackSpaceEndpoint(View):
user_login(request=request, user=user, is_space=True)
# Process workspace and project invitations
# redirect to referer path
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
next_path = validate_next_path(next_path=next_path)
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(base_host(request=request, is_space=True))
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -1,10 +1,10 @@
# Python imports
import uuid
from urllib.parse import urlencode
# Django import
from django.http import HttpResponseRedirect
from django.views import View
from django.utils.http import url_has_allowed_host_and_scheme
# Module imports
from plane.authentication.provider.oauth.google import GoogleOAuthProvider
@@ -15,15 +15,13 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
class GoogleOauthInitiateSpaceEndpoint(View):
def get(self, request):
request.session["host"] = base_host(request=request, is_space=True)
next_path = request.GET.get("next_path")
if next_path:
request.session["next_path"] = str(next_path)
# Check instance configuration
instance = Instance.objects.first()
@@ -33,9 +31,11 @@ class GoogleOauthInitiateSpaceEndpoint(View):
error_message="INSTANCE_NOT_CONFIGURED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
@@ -46,9 +46,11 @@ class GoogleOauthInitiateSpaceEndpoint(View):
return HttpResponseRedirect(auth_url)
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -65,9 +67,11 @@ class GoogleCallbackSpaceEndpoint(View):
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
@@ -75,9 +79,11 @@ class GoogleCallbackSpaceEndpoint(View):
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
try:
provider = GoogleOAuthProvider(request=request, code=code)
@@ -85,11 +91,18 @@ class GoogleCallbackSpaceEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# redirect to referer path
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
next_path = validate_next_path(next_path=next_path)
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(base_host(request=request, is_space=True))
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params
)
return HttpResponseRedirect(url)
@@ -1,10 +1,8 @@
# Python imports
from urllib.parse import urlencode
# Django imports
from django.core.validators import validate_email
from django.http import HttpResponseRedirect
from django.views import View
from django.utils.http import url_has_allowed_host_and_scheme
# Third party imports
from rest_framework import status
@@ -23,7 +21,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
class MagicGenerateSpaceEndpoint(APIView):
@@ -66,9 +64,11 @@ class MagicSignInSpaceEndpoint(View):
error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
existing_user = User.objects.filter(email=email).first()
@@ -79,9 +79,11 @@ class MagicSignInSpaceEndpoint(View):
error_message="USER_DOES_NOT_EXIST",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
# Active User
@@ -93,15 +95,20 @@ class MagicSignInSpaceEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# redirect to referer path
path = str(next_path) if next_path else ""
url = f"{base_host(request=request, is_space=True)}{path}"
return HttpResponseRedirect(url)
next_path = validate_next_path(next_path=next_path)
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(base_host(request=request, is_space=True))
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -120,9 +127,11 @@ class MagicSignUpSpaceEndpoint(View):
error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
# Existing User
existing_user = User.objects.filter(email=email).first()
@@ -133,9 +142,11 @@ class MagicSignUpSpaceEndpoint(View):
error_message="USER_ALREADY_EXIST",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
try:
@@ -146,12 +157,18 @@ class MagicSignUpSpaceEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# redirect to referer path
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
return HttpResponseRedirect(url)
next_path = validate_next_path(next_path=next_path)
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
return HttpResponseRedirect(url)
else:
return HttpResponseRedirect(base_host(request=request, is_space=True))
except AuthenticationException as e:
params = e.get_error_dict()
if next_path:
params["next_path"] = str(validate_next_path(next_path))
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=params,
)
return HttpResponseRedirect(url)
@@ -7,7 +7,7 @@ from django.utils import timezone
# Module imports
from plane.authentication.utils.host import base_host, user_ip
from plane.db.models import User
from plane.utils.path_validator import validate_next_path
from plane.utils.path_validator import get_safe_redirect_url
class SignOutAuthSpaceEndpoint(View):
@@ -22,8 +22,14 @@ class SignOutAuthSpaceEndpoint(View):
user.save()
# Log the user out
logout(request)
url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path
)
return HttpResponseRedirect(url)
except Exception:
url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}"
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path
)
return HttpResponseRedirect(url)
+63 -2
View File
@@ -21,13 +21,14 @@ from plane.db.models import (
PageVersion,
APIActivityLog,
IssueDescriptionVersion,
WebhookLog,
)
from plane.settings.mongo import MongoConnection
from plane.utils.exception_logger import log_exception
logger = logging.getLogger("plane.worker")
BATCH_SIZE = 1000
BATCH_SIZE = 500
def get_mongo_collection(collection_name: str) -> Optional[Collection]:
@@ -247,6 +248,27 @@ def transform_issue_description_version(record: Dict) -> Dict:
}
def transform_webhook_log(record: Dict):
"""Transfer webhook logs to a new destination."""
return {
"id": str(record["id"]),
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"workspace_id": str(record["workspace_id"]),
"webhook": str(record["webhook"]),
# Request
"event_type": str(record["event_type"]),
"request_method": str(record["request_method"]),
"request_headers": str(record["request_headers"]),
"request_body": str(record["request_body"]),
# Response
"response_status": str(record["response_status"]),
"response_body": str(record["response_body"]),
"response_headers": str(record["response_headers"]),
# retry count
"retry_count": str(record["retry_count"]),
}
# Queryset functions for each cleanup task
def get_api_logs_queryset():
"""Get API logs older than cutoff days."""
@@ -374,7 +396,34 @@ def get_issue_description_versions_queryset():
)
# Celery tasks - now much simpler!
def get_webhook_logs_queryset():
"""Get email logs older than cutoff days."""
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
logger.info(f"Webhook logs cutoff time: {cutoff_time}")
return (
WebhookLog.all_objects.filter(created_at__lte=cutoff_time)
.values(
"id",
"created_at",
"workspace_id",
"webhook",
"event_type",
# Request
"request_method",
"request_headers",
"request_body",
# Response
"response_status",
"response_body",
"response_headers",
"retry_count",
)
.iterator(chunk_size=BATCH_SIZE)
)
@shared_task
def delete_api_logs():
"""Delete old API activity logs."""
@@ -421,3 +470,15 @@ def delete_issue_description_versions():
task_name="Issue Description Version",
collection_name="issue_description_versions",
)
@shared_task
def delete_webhook_logs():
"""Delete old webhook logs"""
process_cleanup_task(
queryset_func=get_webhook_logs_queryset,
transform_func=transform_webhook_log,
model=WebhookLog,
task_name="Webhook Log",
collection_name="webhook_logs",
)
+54 -24
View File
@@ -34,6 +34,14 @@ from plane.utils.issue_relation_mapper import get_inverse_relation
from plane.utils.uuid import is_valid_uuid
def extract_ids(data: dict | None, primary_key: str, fallback_key: str) -> set[str]:
if not data:
return set()
if primary_key in data:
return {str(x) for x in data.get(primary_key, [])}
return {str(x) for x in data.get(fallback_key, [])}
# Track Changes in name
def track_name(
requested_data,
@@ -116,18 +124,32 @@ def track_parent(
issue_activities,
epoch,
):
if current_instance.get("parent_id") != requested_data.get("parent_id"):
current_parent_id = current_instance.get("parent_id") or current_instance.get(
"parent"
)
requested_parent_id = requested_data.get("parent_id") or requested_data.get(
"parent"
)
# Validate UUIDs before database queries
if current_parent_id is not None and not is_valid_uuid(current_parent_id):
return
if requested_parent_id is not None and not is_valid_uuid(requested_parent_id):
return
if current_parent_id != requested_parent_id:
old_parent = (
Issue.objects.filter(pk=current_instance.get("parent_id")).first()
if current_instance.get("parent_id") is not None
Issue.objects.filter(pk=current_parent_id).first()
if current_parent_id is not None
else None
)
new_parent = (
Issue.objects.filter(pk=requested_data.get("parent_id")).first()
if requested_data.get("parent_id") is not None
Issue.objects.filter(pk=requested_parent_id).first()
if requested_parent_id is not None
else None
)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
@@ -193,23 +215,31 @@ def track_state(
issue_activities,
epoch,
):
if current_instance.get("state_id") != requested_data.get("state_id"):
new_state = State.objects.get(pk=requested_data.get("state_id", None))
old_state = State.objects.get(pk=current_instance.get("state_id", None))
current_state_id = current_instance.get("state_id") or current_instance.get("state")
requested_state_id = requested_data.get("state_id") or requested_data.get("state")
if current_state_id is not None and not is_valid_uuid(current_state_id):
current_state_id = None
if requested_state_id is not None and not is_valid_uuid(requested_state_id):
requested_state_id = None
if current_state_id != requested_state_id:
new_state = State.objects.filter(pk=requested_state_id, project_id=project_id).first()
old_state = State.objects.filter(pk=current_state_id, project_id=project_id).first()
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor_id=actor_id,
verb="updated",
old_value=old_state.name,
new_value=new_state.name,
old_value=old_state.name if old_state else None,
new_value=new_state.name if new_state else None,
field="state",
project_id=project_id,
workspace_id=workspace_id,
comment="updated the state to",
old_identifier=old_state.id,
new_identifier=new_state.id,
old_identifier=old_state.id if old_state else None,
new_identifier=new_state.id if new_state else None,
epoch=epoch,
)
)
@@ -298,8 +328,10 @@ def track_labels(
issue_activities,
epoch,
):
requested_labels = set([str(lab) for lab in requested_data.get("label_ids", [])])
current_labels = set([str(lab) for lab in current_instance.get("label_ids", [])])
# Labels
requested_labels = extract_ids(requested_data, "label_ids", "labels")
current_labels = extract_ids(current_instance, "label_ids", "labels")
added_labels = requested_labels - current_labels
dropped_labels = current_labels - requested_labels
@@ -364,16 +396,9 @@ def track_assignees(
issue_activities,
epoch,
):
requested_assignees = (
set([str(asg) for asg in requested_data.get("assignee_ids", [])])
if requested_data is not None
else set()
)
current_assignees = (
set([str(asg) for asg in current_instance.get("assignee_ids", [])])
if current_instance is not None
else set()
)
# Assignees
requested_assignees = extract_ids(requested_data, "assignee_ids", "assignees")
current_assignees = extract_ids(current_instance, "assignee_ids", "assignees")
added_assignees = requested_assignees - current_assignees
dropped_assginees = current_assignees - requested_assignees
@@ -631,6 +656,11 @@ def update_issue_activity(
"estimate_point": track_estimate_points,
"archived_at": track_archive_at,
"closed_to": track_closed_to,
# External endpoint keys
"parent": track_parent,
"state": track_state,
"assignees": track_assignees,
"labels": track_labels,
}
requested_data = json.loads(requested_data) if requested_data is not None else None
@@ -30,6 +30,8 @@ def page_version(page_id, existing_instance, user_id):
description_binary=page.description_binary,
owned_by_id=user_id,
last_saved_at=page.updated_at,
description_json=page.description,
description_stripped=page.description_stripped,
)
# If page versions are greater than 20 delete the oldest one
@@ -92,6 +92,10 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
name=workspace.name, # Use workspace name
identifier=project_identifier,
created_by_id=workspace.created_by_id,
# Enable all views in seed data
cycle_view=True,
module_view=True,
issue_views_view=True,
)
# Create project members
+11 -3
View File
@@ -55,15 +55,23 @@ app.conf.beat_schedule = {
},
"check-every-day-to-delete-email-notification-logs": {
"task": "plane.bgtasks.cleanup_task.delete_email_notification_logs",
"schedule": crontab(hour=3, minute=0), # UTC 03:00
"schedule": crontab(hour=2, minute=45), # UTC 02:45
},
"check-every-day-to-delete-page-versions": {
"task": "plane.bgtasks.cleanup_task.delete_page_versions",
"schedule": crontab(hour=3, minute=30), # UTC 03:30
"schedule": crontab(hour=3, minute=0), # UTC 03:00
},
"check-every-day-to-delete-issue-description-versions": {
"task": "plane.bgtasks.cleanup_task.delete_issue_description_versions",
"schedule": crontab(hour=4, minute=0), # UTC 04:00
"schedule": crontab(hour=3, minute=15), # UTC 03:15
},
"check-every-day-to-delete-webhook-logs": {
"task": "plane.bgtasks.cleanup_task.delete_webhook_logs",
"schedule": crontab(hour=3, minute=30), # UTC 03:30
},
"check-every-day-to-delete-exporter-history": {
"task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link",
"schedule": crontab(hour=3, minute=45), # UTC 03:45
},
}
@@ -0,0 +1,33 @@
# Generated by Django 4.2.22 on 2025-09-10 09:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0104_cycleuserproperties_rich_filters_and_more"),
]
operations = [
migrations.AlterField(
model_name="project",
name="cycle_view",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="project",
name="issue_views_view",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="project",
name="module_view",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="session",
name="user_id",
field=models.CharField(db_index=True, max_length=50, null=True),
),
]
@@ -0,0 +1,152 @@
# Generated by Django 4.2.22 on 2025-09-12 08:45
import uuid
import django
from django.conf import settings
from django.db import migrations, models
def set_page_sort_order(apps, schema_editor):
Page = apps.get_model("db", "Page")
batch_size = 3000
sort_order = 100
# Get page IDs ordered by name using the historical model
# This should include all pages regardless of soft-delete status
page_ids = list(Page.objects.all().order_by("name").values_list("id", flat=True))
updated_pages = []
for page_id in page_ids:
# Create page instance with minimal data
updated_pages.append(Page(id=page_id, sort_order=sort_order))
sort_order += 100
# Bulk update when batch is full
if len(updated_pages) >= batch_size:
Page.objects.bulk_update(
updated_pages, ["sort_order"], batch_size=batch_size
)
updated_pages = []
# Update remaining pages
if updated_pages:
Page.objects.bulk_update(updated_pages, ["sort_order"], batch_size=batch_size)
def reverse_set_page_sort_order(apps, schema_editor):
Page = apps.get_model("db", "Page")
Page.objects.update(sort_order=Page.DEFAULT_SORT_ORDER)
class Migration(migrations.Migration):
dependencies = [
("db", "0105_alter_project_cycle_view_and_more"),
]
operations = [
migrations.CreateModel(
name="ProjectWebhook",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"webhook",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_webhooks",
to="db.webhook",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "Project Webhook",
"verbose_name_plural": "Project Webhooks",
"db_table": "project_webhooks",
"ordering": ("-created_at",),
},
),
migrations.AddConstraint(
model_name="projectwebhook",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("project", "webhook"),
name="project_webhook_unique_project_webhook_when_deleted_at_null",
),
),
migrations.AlterUniqueTogether(
name="projectwebhook",
unique_together={("project", "webhook", "deleted_at")},
),
migrations.AlterField(
model_name="issuerelation",
name="relation_type",
field=models.CharField(
default="blocked_by", max_length=20, verbose_name="Issue Relation Type"
),
),
migrations.RunPython(
set_page_sort_order, reverse_code=reverse_set_page_sort_order
),
]
@@ -0,0 +1,74 @@
from django.db import migrations
from plane.utils.filters import LegacyToRichFiltersConverter
from plane.utils.filters.filter_migrations import (
migrate_models_filters_to_rich_filters,
clear_models_rich_filters,
)
# Define all models that need migration in one place
MODEL_NAMES = [
"IssueView",
"WorkspaceUserProperties",
"ModuleUserProperties",
"IssueUserProperty",
"CycleUserProperties",
]
def migrate_filters_to_rich_filters(apps, schema_editor):
"""
Migrate legacy filters to rich_filters format for all models that have both fields.
"""
# Get the model classes from model names
models_to_migrate = {}
for model_name in MODEL_NAMES:
try:
model_class = apps.get_model("db", model_name)
models_to_migrate[model_name] = model_class
except Exception as e:
# Log error but continue with other models
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to get model {model_name}: {str(e)}")
converter = LegacyToRichFiltersConverter()
# Migrate all models
migrate_models_filters_to_rich_filters(models_to_migrate, converter)
def reverse_migrate_rich_filters_to_filters(apps, schema_editor):
"""
Reverse migration to clear rich_filters field for all models.
"""
# Get the model classes from model names
models_to_clear = {}
for model_name in MODEL_NAMES:
try:
model_class = apps.get_model("db", model_name)
models_to_clear[model_name] = model_class
except Exception as e:
# Log error but continue with other models
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to get model {model_name}: {str(e)}")
# Clear rich_filters for all models
clear_models_rich_filters(models_to_clear)
class Migration(migrations.Migration):
dependencies = [
('db', '0106_auto_20250912_0845'),
]
operations = [
migrations.RunPython(
migrate_filters_to_rich_filters,
reverse_code=reverse_migrate_rich_filters_to_filters,
),
]
+1 -1
View File
@@ -284,6 +284,7 @@ class IssueRelationChoices(models.TextChoices):
BLOCKED_BY = "blocked_by", "Blocked By"
START_BEFORE = "start_before", "Start Before"
FINISH_BEFORE = "finish_before", "Finish Before"
IMPLEMENTED_BY = "implemented_by", "Implemented By"
class IssueRelation(ProjectBaseModel):
@@ -295,7 +296,6 @@ class IssueRelation(ProjectBaseModel):
)
relation_type = models.CharField(
max_length=20,
choices=IssueRelationChoices.choices,
verbose_name="Issue Relation Type",
default=IssueRelationChoices.BLOCKED_BY,
)
+2 -1
View File
@@ -19,6 +19,7 @@ def get_view_props():
class Page(BaseModel):
PRIVATE_ACCESS = 1
PUBLIC_ACCESS = 0
DEFAULT_SORT_ORDER = 65535
ACCESS_CHOICES = ((PRIVATE_ACCESS, "Private"), (PUBLIC_ACCESS, "Public"))
@@ -57,7 +58,7 @@ class Page(BaseModel):
)
moved_to_page = models.UUIDField(null=True, blank=True)
moved_to_project = models.UUIDField(null=True, blank=True)
sort_order = models.FloatField(default=65535)
sort_order = models.FloatField(default=DEFAULT_SORT_ORDER)
external_id = models.CharField(max_length=255, null=True, blank=True)
external_source = models.CharField(max_length=255, null=True, blank=True)
+9 -3
View File
@@ -18,6 +18,12 @@ from .base import BaseModel
ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest"))
class ROLE(Enum):
ADMIN = 20
MEMBER = 15
GUEST = 5
class ProjectNetwork(Enum):
SECRET = 0
PUBLIC = 2
@@ -89,9 +95,9 @@ class Project(BaseModel):
)
emoji = models.CharField(max_length=255, null=True, blank=True)
icon_prop = models.JSONField(null=True)
module_view = models.BooleanField(default=True)
cycle_view = models.BooleanField(default=True)
issue_views_view = models.BooleanField(default=True)
module_view = models.BooleanField(default=False)
cycle_view = models.BooleanField(default=False)
issue_views_view = models.BooleanField(default=False)
page_view = models.BooleanField(default=True)
intake_view = models.BooleanField(default=False)
is_time_tracking_enabled = models.BooleanField(default=False)
+1 -1
View File
@@ -13,7 +13,7 @@ VALID_KEY_CHARS = string.ascii_lowercase + string.digits
class Session(AbstractBaseSession):
device_info = models.JSONField(null=True, blank=True, default=None)
session_key = models.CharField(max_length=128, primary_key=True)
user_id = models.CharField(null=True, max_length=50)
user_id = models.CharField(null=True, max_length=50, db_index=True)
@classmethod
def get_session_store_class(cls):
+22 -1
View File
@@ -7,7 +7,7 @@ from django.db import models
from django.core.exceptions import ValidationError
# Module imports
from plane.db.models import BaseModel
from plane.db.models import BaseModel, ProjectBaseModel
def generate_token():
@@ -90,3 +90,24 @@ class WebhookLog(BaseModel):
def __str__(self):
return f"{self.event_type} {str(self.webhook)}"
class ProjectWebhook(ProjectBaseModel):
webhook = models.ForeignKey(
"db.Webhook", on_delete=models.CASCADE, related_name="project_webhooks"
)
class Meta:
unique_together = ["project", "webhook", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["project", "webhook"],
condition=models.Q(deleted_at__isnull=True),
name="project_webhook_unique_project_webhook_when_deleted_at_null",
)
]
verbose_name = "Project Webhook"
verbose_name_plural = "Project Webhooks"
db_table = "project_webhooks"
ordering = ("-created_at",)
+10 -2
View File
@@ -34,6 +34,7 @@ from plane.authentication.adapter.error import (
AuthenticationException,
)
from plane.utils.ip_address import get_client_ip
from plane.utils.path_validator import get_safe_redirect_url
class InstanceAdminEndpoint(BaseAPIView):
@@ -392,7 +393,14 @@ class InstanceAdminSignOutEndpoint(View):
user.save()
# Log the user out
logout(request)
url = urljoin(base_host(request=request, is_admin=True))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_admin=True),
next_path=""
)
return HttpResponseRedirect(url)
except Exception:
return HttpResponseRedirect(base_host(request=request, is_admin=True))
url = get_safe_redirect_url(
base_url=base_host(request=request, is_admin=True),
next_path=""
)
return HttpResponseRedirect(url)
@@ -0,0 +1,18 @@
# Generated by Django 4.2.22 on 2025-09-11 08:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("license", "0005_rename_product_instance_edition_and_more"),
]
operations = [
migrations.AddField(
model_name="instance",
name="is_current_version_deprecated",
field=models.BooleanField(default=False),
),
]
@@ -38,6 +38,8 @@ class Instance(BaseModel):
is_signup_screen_visited = models.BooleanField(default=False)
is_verified = models.BooleanField(default=False)
is_test = models.BooleanField(default=False)
# field for validating if the current version is deprecated
is_current_version_deprecated = models.BooleanField(default=False)
class Meta:
verbose_name = "Instance"
+4 -18
View File
@@ -184,6 +184,7 @@ def issue_group_values(
slug: str,
project_id: Optional[str] = None,
filters: Dict[str, Any] = {},
queryset: Optional[QuerySet] = None,
) -> List[Union[str, Any]]:
if field == "state_id":
queryset = State.objects.filter(
@@ -238,35 +239,20 @@ def issue_group_values(
if field == "state__group":
return ["backlog", "unstarted", "started", "completed", "cancelled"]
if field == "target_date":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("target_date", flat=True)
.distinct()
)
queryset = queryset.values_list("target_date", flat=True).distinct()
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "start_date":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("start_date", flat=True)
.distinct()
)
queryset = queryset.values_list("start_date", flat=True).distinct()
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "created_by":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("created_by", flat=True)
.distinct()
)
queryset = queryset.values_list("created_by", flat=True).distinct()
if project_id:
return list(queryset.filter(project_id=project_id))
else:
+46 -40
View File
@@ -67,20 +67,9 @@ def validate_binary_data(data):
# Combine custom components and editor-specific nodes into a single set of tags
CUSTOM_TAGS = {
# editor node/tag names
"imageComponent",
"image",
"mention",
"link",
"customColor",
"emoji",
"tableHeader",
"tableCell",
"tableRow",
"codeBlock",
"code",
"horizontalRule",
"calloutComponent",
# component-style tag used by editor embeds
"mention-component",
"label",
"input",
"image-component",
}
ALLOWED_TAGS = nh3.ALLOWED_TAGS | CUSTOM_TAGS
@@ -95,6 +84,8 @@ ATTRIBUTES = {
"aria-label",
"aria-hidden",
"style",
"start",
"type",
# common editor data-* attributes seen in stored HTML
# (wildcards like data-* are NOT supported by nh3; we add known keys
# here and dynamically include all data-* seen in the input below)
@@ -102,49 +93,64 @@ ATTRIBUTES = {
"data-node-type",
"data-type",
"data-checked",
"data-background",
"data-background-color",
"data-text-color",
"data-name",
# callout attributes
"data-icon-name",
"data-icon-color",
"data-background-color",
"data-background",
"data-emoji-unicode",
"data-emoji-url",
"data-logo-in-use",
"data-block-type",
"data-name",
"data-entity-id",
"data-entity-group-id",
},
"a": {"href", "target"},
# editor node/tag attributes
"imageComponent": {"id", "width", "height", "aspectRatio", "src", "alignment"},
"image": {"width", "height", "aspectRatio", "alignment", "src", "alt", "title"},
"mention": {"id", "entity_identifier", "entity_name"},
"link": {"href", "target"},
"customColor": {"color", "backgroundColor"},
"emoji": {"name"},
"tableHeader": {"colspan", "rowspan", "colwidth", "background", "hideContent"},
"tableCell": {
"image-component": {
"id",
"width",
"height",
"aspectRatio",
"aspectratio",
"src",
"alignment",
},
"img": {
"width",
"height",
"aspectRatio",
"aspectratio",
"alignment",
"src",
"alt",
"title",
},
"mention-component": {"id", "entity_identifier", "entity_name"},
"th": {
"colspan",
"rowspan",
"colwidth",
"background",
"hideContent",
"hidecontent",
"style",
},
"td": {
"colspan",
"rowspan",
"colwidth",
"background",
"textColor",
"textcolor",
"hideContent",
"hidecontent",
"style",
},
"tableRow": {"background", "textColor"},
"codeBlock": {"language"},
"calloutComponent": {
"data-icon-color",
"data-icon-name",
"data-emoji-unicode",
"data-emoji-url",
"data-logo-in-use",
"data-background",
"data-block-type",
},
# image-component (from editor extension and seeds)
"image-component": {"src", "id", "width", "height", "aspectratio", "alignment"},
"tr": {"background", "textColor", "textcolor", "style"},
"pre": {"language"},
"code": {"language", "spellcheck"},
"input": {"type", "checked"},
}
SAFE_PROTOCOLS = {"http", "https", "mailto", "tel"}
+10
View File
@@ -0,0 +1,10 @@
# Filters module for handling complex filtering operations
# Import all utilities from base modules
from .filter_backend import ComplexFilterBackend
from .converters import LegacyToRichFiltersConverter
from .filterset import IssueFilterSet
# Public API exports
__all__ = ["ComplexFilterBackend", "LegacyToRichFiltersConverter", "IssueFilterSet"]
+438
View File
@@ -0,0 +1,438 @@
import re
import uuid
from datetime import datetime
from typing import Any, Dict, List, Union
from dateutil.parser import parse as dateutil_parse
class LegacyToRichFiltersConverter:
# Default mapping from legacy filter names to new rich filter field names
DEFAULT_FIELD_MAPPINGS = {
"state": "state_id",
"labels": "label_id",
"cycle": "cycle_id",
"module": "module_id",
"assignees": "assignee_id",
"mentions": "mention_id",
"created_by": "created_by_id",
"state_group": "state_group",
"priority": "priority",
"project": "project_id",
"start_date": "start_date",
"target_date": "target_date",
}
# Default fields that expect UUID values
DEFAULT_UUID_FIELDS = {
"state_id",
"label_id",
"cycle_id",
"module_id",
"assignee_id",
"mention_id",
"created_by_id",
"project_id",
}
# Default valid choices for choice fields
DEFAULT_VALID_CHOICES = {
"state_group": ["backlog", "unstarted", "started", "completed", "cancelled"],
"priority": ["urgent", "high", "medium", "low", "none"],
}
# Default date fields
DEFAULT_DATE_FIELDS = {"start_date", "target_date"}
# Pattern for relative date strings like "2_weeks" or "3_months"
DATE_PATTERN = re.compile(r"(\d+)_(weeks|months)$")
def __init__(
self,
field_mappings: Dict[str, str] = None,
uuid_fields: set = None,
valid_choices: Dict[str, List[str]] = None,
date_fields: set = None,
extend_defaults: bool = True,
):
"""
Initialize the converter with optional custom configurations.
Args:
field_mappings: Custom field mappings (legacy_key -> rich_field_name)
uuid_fields: Set of field names that should be validated as UUIDs
valid_choices: Dict of valid choices for choice fields
date_fields: Set of field names that should be treated as dates
extend_defaults: If True, merge with defaults; if False, replace defaults
Examples:
# Use defaults
converter = LegacyToRichFiltersConverter()
# Add custom field mapping
converter = LegacyToRichFiltersConverter(
field_mappings={"custom_field": "custom_field_id"}
)
# Override priority choices
converter = LegacyToRichFiltersConverter(
valid_choices={"priority": ["critical", "high", "medium", "low"]}
)
# Complete replacement (not extending defaults)
converter = LegacyToRichFiltersConverter(
field_mappings={"state": "status_id"},
extend_defaults=False
)
"""
if extend_defaults:
# Merge with defaults
self.FIELD_MAPPINGS = {**self.DEFAULT_FIELD_MAPPINGS}
if field_mappings:
self.FIELD_MAPPINGS.update(field_mappings)
self.UUID_FIELDS = {*self.DEFAULT_UUID_FIELDS}
if uuid_fields:
self.UUID_FIELDS.update(uuid_fields)
self.VALID_CHOICES = {**self.DEFAULT_VALID_CHOICES}
if valid_choices:
self.VALID_CHOICES.update(valid_choices)
self.DATE_FIELDS = {*self.DEFAULT_DATE_FIELDS}
if date_fields:
self.DATE_FIELDS.update(date_fields)
else:
# Replace defaults entirely
self.FIELD_MAPPINGS = field_mappings or {}
self.UUID_FIELDS = uuid_fields or set()
self.VALID_CHOICES = valid_choices or {}
self.DATE_FIELDS = date_fields or set()
def add_field_mapping(self, legacy_key: str, rich_field_name: str) -> None:
"""Add or update a single field mapping."""
self.FIELD_MAPPINGS[legacy_key] = rich_field_name
def add_uuid_field(self, field_name: str) -> None:
"""Add a field that should be validated as UUID."""
self.UUID_FIELDS.add(field_name)
def add_choice_field(self, field_name: str, choices: List[str]) -> None:
"""Add or update valid choices for a choice field."""
self.VALID_CHOICES[field_name] = choices
def add_date_field(self, field_name: str) -> None:
"""Add a field that should be treated as a date field."""
self.DATE_FIELDS.add(field_name)
def update_mappings(
self,
field_mappings: Dict[str, str] = None,
uuid_fields: set = None,
valid_choices: Dict[str, List[str]] = None,
date_fields: set = None,
) -> None:
"""
Update multiple configurations at once.
Args:
field_mappings: Additional field mappings to add/update
uuid_fields: Additional UUID fields to add
valid_choices: Additional choice fields to add/update
date_fields: Additional date fields to add
"""
if field_mappings:
self.FIELD_MAPPINGS.update(field_mappings)
if uuid_fields:
self.UUID_FIELDS.update(uuid_fields)
if valid_choices:
self.VALID_CHOICES.update(valid_choices)
if date_fields:
self.DATE_FIELDS.update(date_fields)
def _validate_uuid(self, value: str) -> bool:
"""Validate if a string is a valid UUID"""
try:
uuid.UUID(str(value))
return True
except (ValueError, TypeError):
return False
def _validate_choice(self, field_name: str, value: str) -> bool:
"""Validate if a value is valid for a choice field"""
if field_name not in self.VALID_CHOICES:
return True # No validation needed for this field
return value in self.VALID_CHOICES[field_name]
def _validate_date(self, value: Union[str, datetime]) -> bool:
"""Validate if a value is a valid date using dateutil parser"""
if isinstance(value, datetime):
return True
if isinstance(value, str):
try:
# Use dateutil for flexible date parsing
dateutil_parse(value)
return True
except (ValueError, TypeError):
return False
return False
def _validate_value(self, rich_field_name: str, value: Any) -> bool:
"""Validate a single value based on field type"""
if rich_field_name in self.UUID_FIELDS:
return self._validate_uuid(value)
elif rich_field_name in self.VALID_CHOICES:
return self._validate_choice(rich_field_name, value)
elif rich_field_name in self.DATE_FIELDS:
return self._validate_date(value)
return True # No specific validation needed
def _filter_valid_values(
self, rich_field_name: str, values: List[Any]
) -> List[Any]:
"""Filter out invalid values from a list and return only valid ones"""
valid_values = []
for value in values:
if self._validate_value(rich_field_name, value):
valid_values.append(value)
return valid_values
def _add_validation_error(
self, strict: bool, validation_errors: List[str], message: str
) -> None:
"""Add validation error if in strict mode."""
if strict:
validation_errors.append(message)
def _add_rich_filter(
self, rich_filters: Dict[str, Any], field_name: str, operator: str, value: Any
) -> None:
"""Add a rich filter with proper field name formatting."""
# Convert lists to comma-separated strings for 'in' and 'range' operations
if operator in ("in", "range") and isinstance(value, list):
value = ",".join(str(v) for v in value)
rich_filters[f"{field_name}__{operator}"] = value
def _handle_value_error(
self, e: ValueError, strict: bool, validation_errors: List[str]
) -> None:
"""Handle ValueError with consistent strict/non-strict behavior."""
if strict:
validation_errors.append(str(e))
# In non-strict mode, we just skip (no action needed)
def _process_date_field(
self,
rich_field_name: str,
values: List[str],
strict: bool,
validation_errors: List[str],
rich_filters: Dict[str, Any],
) -> bool:
"""Process date field with basic functionality (exact, range)."""
if rich_field_name not in self.DATE_FIELDS:
return False
try:
date_filter_result = self._convert_date_value(
rich_field_name, values, strict
)
if date_filter_result:
rich_filters.update(date_filter_result)
return True
except ValueError as e:
self._handle_value_error(e, strict, validation_errors)
return True
def _convert_date_value(
self, field_name: str, values: List[str], strict: bool = False
) -> Dict[str, Any]:
"""
Convert legacy date values to rich filter format - basic implementation.
Supports:
- Simple dates: "2023-01-01" -> __exact
- Basic ranges: ["2023-01-01;after", "2023-12-31;before"] -> __range
- Skips complex or relative date patterns
Args:
field_name: Name of the rich filter field
values: List of legacy date values
strict: If True, raise errors for validation failures
Raises:
ValueError: For malformed date patterns (strict mode)
"""
# Check for relative dates and skip the entire field if found
for value in values:
if ";" in value:
parts = value.split(";")
if len(parts) > 0 and self.DATE_PATTERN.match(parts[0]):
# Skip relative date patterns entirely
return {}
# Skip complex conditions (more than 2 values)
if len(values) > 2:
return {}
# Process each date value
exact_dates = []
after_dates = []
before_dates = []
for value in values:
if ";" not in value:
# Simple date string
if not self._validate_date(value):
if strict:
raise ValueError(f"Invalid date format: {value}")
continue
exact_dates.append(value)
else:
# Directional date - only handle basic after/before
parts = value.split(";")
if len(parts) < 2:
if strict:
raise ValueError(f"Invalid date format: {value}")
continue
date_part = parts[0]
direction = parts[1]
if not self._validate_date(date_part):
if strict:
raise ValueError(f"Invalid date format: {date_part}")
continue
if direction == "after":
after_dates.append(date_part)
elif direction == "before":
before_dates.append(date_part)
# Skip unsupported directions
# Determine return format
result = {}
if len(after_dates) == 1 and len(before_dates) == 1 and len(exact_dates) == 0:
# Simple range: one after and one before
start_date = min(after_dates[0], before_dates[0])
end_date = max(after_dates[0], before_dates[0])
self._add_rich_filter(result, field_name, "range", [start_date, end_date])
elif len(exact_dates) == 1 and len(after_dates) == 0 and len(before_dates) == 0:
# Single exact date
self._add_rich_filter(result, field_name, "exact", exact_dates[0])
# Skip all other combinations
return result
def convert(self, legacy_filters: dict, strict: bool = False) -> Dict[str, Any]:
"""
Convert legacy filters to rich filters format with validation
Args:
legacy_filters: Dictionary of legacy filters
strict: If True, raise exception on validation errors.
If False, skip invalid values (default behavior)
Returns:
Dictionary of rich filters
Raises:
ValueError: If strict=True and validation fails
"""
rich_filters = {}
validation_errors = []
for legacy_key, value in legacy_filters.items():
# Skip if value is None or empty
if value is None or (isinstance(value, list) and len(value) == 0):
continue
# Skip if legacy key is not in our mappings (not supported in filterset)
if legacy_key not in self.FIELD_MAPPINGS:
self._add_validation_error(
strict, validation_errors, f"Unsupported filter key: {legacy_key}"
)
continue
# Get the new field name
rich_field_name = self.FIELD_MAPPINGS[legacy_key]
# Handle list values
if isinstance(value, list):
# Process date fields with helper method
if self._process_date_field(
rich_field_name, value, strict, validation_errors, rich_filters
):
continue
# Regular non-date field processing
# Filter out invalid values
valid_values = self._filter_valid_values(rich_field_name, value)
if not valid_values:
self._add_validation_error(
strict,
validation_errors,
f"No valid values found for {legacy_key}: {value}",
)
continue
# Check for invalid values if in strict mode
if strict and len(valid_values) != len(value):
invalid_values = [v for v in value if v not in valid_values]
self._add_validation_error(
strict,
validation_errors,
f"Invalid values for {legacy_key}: {invalid_values}",
)
# For list values, always use __in operator for non-date fields
self._add_rich_filter(rich_filters, rich_field_name, "in", valid_values)
else:
# Handle single values
# Process date fields with helper method
if self._process_date_field(
rich_field_name, [value], strict, validation_errors, rich_filters
):
continue
# For non-list values, use __exact operator for non-date fields
if self._validate_value(rich_field_name, value):
self._add_rich_filter(rich_filters, rich_field_name, "exact", value)
else:
error_msg = f"Invalid value for {legacy_key}: {value}"
self._add_validation_error(strict, validation_errors, error_msg)
# Raise validation errors if in strict mode
if strict and validation_errors:
error_message = f"Filter validation errors: {'; '.join(validation_errors)}"
raise ValueError(error_message)
# Convert flat dict to rich filter format
return self._format_as_rich_filter(rich_filters)
def _format_as_rich_filter(self, flat_filters: Dict[str, Any]) -> Dict[str, Any]:
"""
Convert a flat dictionary of filters to the proper rich filter format.
Args:
flat_filters: Dictionary with field__lookup keys and values
Returns:
Rich filter format using logical operators (and/or/not)
"""
if not flat_filters:
return {}
# If only one filter, return as leaf node
if len(flat_filters) == 1:
key, value = next(iter(flat_filters.items()))
return {key: value}
# Multiple filters: wrap in 'and' operator
filter_conditions = []
for key, value in flat_filters.items():
filter_conditions.append({key: value})
return {"and": filter_conditions}
@@ -0,0 +1,380 @@
import json
from django.core.exceptions import ValidationError
from django.http import QueryDict
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters
class ComplexFilterBackend(filters.BaseFilterBackend):
"""
Filter backend that supports complex JSON filtering.
For full, up-to-date examples and usage, see the package README
at `plane/utils/filters/README.md`.
"""
filter_param = "filters"
default_max_depth = 5
def filter_queryset(self, request, queryset, view, filter_data=None):
"""Normalize filter input and apply JSON-based filtering.
Accepts explicit `filter_data` (dict or JSON string) or reads the
`filter` query parameter. Enforces JSON-only filtering.
"""
try:
if filter_data is not None:
normalized = self._normalize_filter_data(filter_data, "filter_data")
return self._apply_json_filter(queryset, normalized, view)
filter_string = request.query_params.get(self.filter_param, None)
if not filter_string:
return queryset
normalized = self._normalize_filter_data(filter_string, "filter")
return self._apply_json_filter(queryset, normalized, view)
except ValidationError:
# Propagate validation errors unchanged
raise
except Exception as e:
raise
# Convert unexpected errors to ValidationError to keep response consistent
raise ValidationError(f"Filter error: {str(e)}")
def _normalize_filter_data(self, raw_filter, source_label):
"""Return a dict from raw filter input or raise a ValidationError.
- raw_filter may be a dict or a JSON string
- source_label is used in error messages (e.g., 'filter_data' or 'filter')
"""
try:
if isinstance(raw_filter, str):
return json.loads(raw_filter)
if isinstance(raw_filter, dict):
return raw_filter
raise ValidationError(f"'{source_label}' must be a dict or a JSON string.")
except json.JSONDecodeError:
raise ValidationError(
f"Invalid JSON for '{source_label}'. Expected a valid JSON object."
)
def _apply_json_filter(self, queryset, filter_data, view):
"""Process a JSON filter structure using OR/AND/NOT set operations."""
if not filter_data:
return queryset
# Validate structure and depth before field allowlist checks
max_depth = self._get_max_depth(view)
self._validate_structure(filter_data, max_depth=max_depth, current_depth=1)
# Validate against the view's FilterSet (only declared filters are allowed)
self._validate_fields(filter_data, view)
# Build combined queryset using FilterSet-driven leaf evaluation
combined_qs = self._evaluate_node(filter_data, queryset, view)
if combined_qs is None:
return queryset
return combined_qs
def _validate_fields(self, filter_data, view):
"""Validate that filtered fields are defined in the view's FilterSet."""
filterset_class = getattr(view, "filterset_class", None)
allowed_fields = (
set(filterset_class.base_filters.keys()) if filterset_class else None
)
if not allowed_fields:
# If no FilterSet is configured, reject filtering to avoid unintended exposure # noqa: E501
raise ValidationError(
"Filtering is not enabled for this endpoint (missing filterset_class)"
)
# Extract field names from the filter data
fields = self._extract_field_names(filter_data)
# Check if all fields are allowed
for field in fields:
# Field keys must match FilterSet filter names (including any lookups)
# Example: 'sequence_id__gte' should be declared in base_filters
# Special-case __range: require the '<base>__range' filter itself
if field not in allowed_fields:
raise ValidationError(f"Filtering on field '{field}' is not allowed")
def _extract_field_names(self, filter_data):
"""Extract all field names from a nested filter structure"""
if isinstance(filter_data, dict):
fields = []
for key, value in filter_data.items():
if key.lower() in ("or", "and", "not"):
# This is a logical operator, process its children
if key.lower() == "not":
# 'not' has a dict as its value, not a list
if isinstance(value, dict):
fields.extend(self._extract_field_names(value))
else:
# 'or' and 'and' have lists as their values
for item in value:
fields.extend(self._extract_field_names(item))
else:
# This is a field name
fields.append(key)
return fields
return []
def _evaluate_node(self, node, base_queryset, view):
"""
Recursively evaluate a JSON node into a combined queryset using branch-based filtering.
Rules:
- leaf dict → evaluated through DjangoFilterBackend as a mini-querystring
- {"or": [...]} → union (|) of children
- {"and": [...]} → collect field conditions per branch and apply together
- {"not": {...}} → exclude child's rows from the base queryset
(complement within base scope)
"""
if not isinstance(node, dict):
return None
# 'or' combination - requires set operations between children
if "or" in node:
children = node["or"]
if not isinstance(children, list) or not children:
return None
combined = None
for child in children:
child_qs = self._evaluate_node(child, base_queryset, view)
if child_qs is None:
continue
combined = child_qs if combined is None else (combined | child_qs)
return combined
# 'and' combination - collect field conditions per branch
if "and" in node:
children = node["and"]
if not isinstance(children, list) or not children:
return None
return self._evaluate_and_branch(children, base_queryset, view)
# 'not' negation
if "not" in node:
child = node["not"]
if not isinstance(child, dict):
return None
child_qs = self._evaluate_node(child, base_queryset, view)
if child_qs is None:
return None
# Use subquery instead of pk__in for better performance
# This avoids evaluating child_qs and creating large IN clauses
return base_queryset.exclude(pk__in=child_qs.values("pk"))
# Leaf dict: evaluate via DjangoFilterBackend using FilterSet
return self._filter_leaf_via_backend(node, base_queryset, view)
def _evaluate_and_branch(self, children, base_queryset, view):
"""
Evaluate an AND branch by collecting field conditions and applying them together.
This approach is more efficient than individual leaf evaluation because:
- Field conditions within the same AND branch are collected and applied together
- Only logical operation children require separate evaluation and set intersection
- Reduces the number of intermediate querysets and database queries
"""
collected_conditions = {}
logical_querysets = []
# Separate field conditions from logical operations
for child in children:
if not isinstance(child, dict):
continue
# Check if this child contains logical operators
has_logical = any(
k.lower() in ("or", "and", "not")
for k in child.keys()
if isinstance(k, str)
)
if has_logical:
# This child has logical operators, evaluate separately
child_qs = self._evaluate_node(child, base_queryset, view)
if child_qs is not None:
logical_querysets.append(child_qs)
else:
# This is a leaf with field conditions, collect them
collected_conditions.update(child)
# Start with base queryset
result_qs = base_queryset
# Apply collected field conditions together if any exist
if collected_conditions:
result_qs = self._filter_leaf_via_backend(
collected_conditions, result_qs, view
)
if result_qs is None:
return None
# Intersect with any logical operation results
for logical_qs in logical_querysets:
result_qs = result_qs & logical_qs
return result_qs
def _filter_leaf_via_backend(self, leaf_conditions, base_queryset, view):
"""Evaluate a leaf dict by delegating to DjangoFilterBackend once.
We serialize the leaf dict into a mini querystring and let the view's
filterset_class perform validation, conversion, and filtering. This returns
a lazy queryset suitable for set-operations with siblings.
"""
if not leaf_conditions:
return None
# Build a QueryDict from the leaf conditions
qd = QueryDict(mutable=True)
for key, value in leaf_conditions.items():
# Default serialization to string; QueryDict expects strings
if isinstance(value, list):
# Repeat key for list values (e.g., __in)
qd.setlist(key, [str(v) for v in value])
else:
qd[key] = "" if value is None else str(value)
qd = qd.copy()
qd._mutable = False
# Temporarily patch request.GET and delegate to DjangoFilterBackend
backend = DjangoFilterBackend()
request = view.request
original_get = request._request.GET if hasattr(request, "_request") else None
try:
if hasattr(request, "_request"):
request._request.GET = qd
return backend.filter_queryset(request, base_queryset, view)
finally:
if hasattr(request, "_request") and original_get is not None:
request._request.GET = original_get
def _get_max_depth(self, view):
"""Return the maximum allowed nesting depth for complex filters.
Falls back to class default if the view does not specify it or has
an invalid value.
"""
value = getattr(view, "complex_filter_max_depth", self.default_max_depth)
try:
value_int = int(value)
if value_int <= 0:
return self.default_max_depth
return value_int
except Exception:
return self.default_max_depth
def _validate_structure(self, node, max_depth, current_depth):
"""Validate JSON structure and enforce nesting depth.
Rules:
- Each object may contain only one logical operator:
or/and/not (case-insensitive)
- Logical operator objects cannot contain field keys alongside the
operator
- or/and values must be non-empty lists of dicts
- not value must be a dict
- Leaf objects must only contain field keys and acceptable values
- Depth must not exceed max_depth
"""
if current_depth > max_depth:
raise ValidationError(
f"Filter nesting is too deep (max {max_depth}); found depth"
f" {current_depth}"
)
if not isinstance(node, dict):
raise ValidationError("Each filter node must be a JSON object")
if not node:
raise ValidationError("Filter objects must not be empty")
logical_keys = [
k
for k in node.keys()
if isinstance(k, str) and k.lower() in ("or", "and", "not")
]
if len(logical_keys) > 1:
raise ValidationError(
"A filter object cannot contain multiple logical operators at"
" the same level"
)
if len(logical_keys) == 1:
op_key = logical_keys[0]
# must not mix operator with other keys
if len(node) != 1:
raise ValidationError(
f"Cannot mix logical operator '{op_key}' with field keys at"
f" the same level"
)
op = op_key.lower()
value = node[op_key]
if op in ("or", "and"):
if not isinstance(value, list) or len(value) == 0:
raise ValidationError(
f"'{op}' must be a non-empty list of filter objects"
)
for child in value:
if not isinstance(child, dict):
raise ValidationError(
f"All children of '{op}' must be JSON objects"
)
self._validate_structure(
child,
max_depth=max_depth,
current_depth=current_depth + 1,
)
return
if op == "not":
if not isinstance(value, dict):
raise ValidationError("'not' must be a single JSON object")
self._validate_structure(
value, max_depth=max_depth, current_depth=current_depth + 1
)
return
# Leaf node: validate fields and values
self._validate_leaf(node)
def _validate_leaf(self, leaf):
"""Validate a leaf dict containing field lookups and values."""
if not isinstance(leaf, dict) or not leaf:
raise ValidationError("Leaf filter must be a non-empty JSON object")
for key, value in leaf.items():
if isinstance(key, str) and key.lower() in ("or", "and", "not"):
raise ValidationError(
"Logical operators cannot appear in a leaf filter object"
)
# Lists/Tuples must contain only scalar values
if isinstance(value, (list, tuple)):
if len(value) == 0:
raise ValidationError(f"List value for '{key}' must not be empty")
for item in value:
if not self._is_scalar(item):
raise ValidationError(
f"List value for '{key}' must contain only scalar items"
)
continue
# Scalars and None are allowed
if not self._is_scalar(value):
raise ValidationError(
f"Value for '{key}' must be a scalar, null, or list/tuple of"
f" scalars"
)
def _is_scalar(self, value):
return value is None or isinstance(value, (str, int, float, bool))
@@ -0,0 +1,146 @@
"""
Utilities for migrating legacy filters to rich filters format.
This module contains helper functions for data migrations that convert
filters fields to rich_filters fields using the LegacyToRichFiltersConverter.
"""
import logging
from typing import Any, Dict, Tuple
from .converters import LegacyToRichFiltersConverter
logger = logging.getLogger("plane.api.filters.migration")
def migrate_single_model_filters(
model_class, model_name: str, converter: LegacyToRichFiltersConverter
) -> Tuple[int, int]:
"""
Migrate filters to rich_filters for a single model.
Args:
model_class: Django model class
model_name: Human-readable name for logging
converter: Instance of LegacyToRichFiltersConverter
Returns:
Tuple of (updated_count, error_count)
"""
# Find records that need migration - have filters but empty rich_filters
records_to_migrate = model_class.objects.exclude(filters={}).filter(rich_filters={})
if records_to_migrate.count() == 0:
logger.info(f"No {model_name} records need migration")
return 0, 0
logger.info(f"Found {records_to_migrate.count()} {model_name} records to migrate")
updated_records = []
conversion_errors = 0
for record in records_to_migrate:
try:
if record.filters: # Double check that filters is not empty
rich_filters = converter.convert(record.filters, strict=False)
record.rich_filters = rich_filters
updated_records.append(record)
except Exception as e:
logger.warning(
f"Failed to convert filters for {model_name} ID {record.id}: {str(e)}"
)
conversion_errors += 1
continue
# Bulk update all successfully converted records
if updated_records:
model_class.objects.bulk_update(
updated_records, ["rich_filters"], batch_size=1000
)
logger.info(f"Successfully updated {len(updated_records)} {model_name} records")
return len(updated_records), conversion_errors
def migrate_models_filters_to_rich_filters(
models_to_migrate: Dict[str, Any],
converter: LegacyToRichFiltersConverter,
) -> Dict[str, Tuple[int, int]]:
"""
Migrate legacy filters to rich_filters format for provided models.
Args:
models_to_migrate: Dict mapping model names to model classes
Returns:
Dictionary mapping model names to (updated_count, error_count) tuples
"""
# Initialize the converter with default settings
logger.info("Starting filters to rich_filters migration for all models")
results = {}
total_updated = 0
total_errors = 0
for model_name, model_class in models_to_migrate.items():
try:
updated_count, error_count = migrate_single_model_filters(
model_class, model_name, converter
)
results[model_name] = (updated_count, error_count)
total_updated += updated_count
total_errors += error_count
except Exception as e:
logger.error(f"Failed to migrate {model_name}: {str(e)}")
results[model_name] = (0, 1)
total_errors += 1
continue
# Log final summary
logger.info(
f"Migration completed for all models. Total updated: {total_updated}, "
f"Total errors: {total_errors}"
)
return results
def clear_models_rich_filters(models_to_clear: Dict[str, Any]) -> Dict[str, int]:
"""
Clear rich_filters field for provided models (for reverse migration).
Args:
models_to_clear: Dictionary mapping model names to model classes
Returns:
Dictionary mapping model names to count of cleared records
"""
logger.info("Starting reverse migration - clearing rich_filters for all models")
results = {}
total_cleared = 0
for model_name, model_class in models_to_clear.items():
try:
# Clear rich_filters for all records that have them
updated_count = model_class.objects.exclude(rich_filters={}).update(
rich_filters={}
)
results[model_name] = updated_count
total_cleared += updated_count
logger.info(
f"Cleared rich_filters for {updated_count} {model_name} records"
)
except Exception as e:
logger.error(f"Failed to clear rich_filters for {model_name}: {str(e)}")
results[model_name] = 0
continue
logger.info(f"Reverse migration completed. Total cleared: {total_cleared}")
return results
+180
View File
@@ -0,0 +1,180 @@
import copy
from django_filters import FilterSet, filters
from plane.db.models import Issue
class UUIDInFilter(filters.BaseInFilter, filters.UUIDFilter):
pass
class CharInFilter(filters.BaseInFilter, filters.CharFilter):
pass
class BaseFilterSet(FilterSet):
@classmethod
def get_filters(cls):
"""
Get all filters for the filterset, including dynamically created __exact filters.
"""
# Get the standard filters first
filters = super().get_filters()
# Add __exact versions for filters that have 'exact' lookup
exact_filters = {}
for filter_name, filter_obj in filters.items():
if hasattr(filter_obj, "lookup_expr") and filter_obj.lookup_expr == "exact":
exact_field_name = f"{filter_name}__exact"
if exact_field_name not in filters:
# Copy the filter object as-is and assign it to the new name
exact_filters[exact_field_name] = copy.deepcopy(filter_obj)
# Add the exact filters to the main filters dict
filters.update(exact_filters)
return filters
class IssueFilterSet(BaseFilterSet):
# Custom filter methods to handle soft delete exclusion for relations
assignee_id = filters.UUIDFilter(method="filter_assignee_id")
assignee_id__in = UUIDInFilter(method="filter_assignee_id_in", lookup_expr="in")
cycle_id = filters.UUIDFilter(method="filter_cycle_id")
cycle_id__in = UUIDInFilter(method="filter_cycle_id_in", lookup_expr="in")
module_id = filters.UUIDFilter(method="filter_module_id")
module_id__in = UUIDInFilter(method="filter_module_id_in", lookup_expr="in")
mention_id = filters.UUIDFilter(method="filter_mention_id")
mention_id__in = UUIDInFilter(method="filter_mention_id_in", lookup_expr="in")
label_id = filters.UUIDFilter(method="filter_label_id")
label_id__in = UUIDInFilter(method="filter_label_id_in", lookup_expr="in")
# Direct field lookups remain the same
created_by_id = filters.UUIDFilter(field_name="created_by_id")
created_by_id__in = UUIDInFilter(field_name="created_by_id", lookup_expr="in")
is_archived = filters.BooleanFilter(method="filter_is_archived")
state_group = filters.CharFilter(field_name="state__group")
state_group__in = CharInFilter(field_name="state__group", lookup_expr="in")
state_id = filters.UUIDFilter(field_name="state_id")
state_id__in = UUIDInFilter(field_name="state_id", lookup_expr="in")
project_id = filters.UUIDFilter(field_name="project_id")
project_id__in = UUIDInFilter(field_name="project_id", lookup_expr="in")
subscriber_id = filters.UUIDFilter(method="filter_subscriber_id")
subscriber_id__in = UUIDInFilter(method="filter_subscriber_id_in", lookup_expr="in")
class Meta:
model = Issue
fields = {
"start_date": ["exact", "range"],
"target_date": ["exact", "range"],
"created_at": ["exact", "range"],
"is_draft": ["exact"],
"priority": ["exact", "in"],
}
def filter_is_archived(self, queryset, name, value):
"""
Convenience filter: archived=true -> archived_at is not null,
archived=false -> archived_at is null
"""
if value in (True, "true", "True", 1, "1"):
return queryset.filter(archived_at__isnull=False)
if value in (False, "false", "False", 0, "0"):
return queryset.filter(archived_at__isnull=True)
return queryset
# Filter methods with soft delete exclusion for relations
def filter_assignee_id(self, queryset, name, value):
"""Filter by assignee ID, excluding soft deleted users"""
return queryset.filter(
issue_assignee__assignee_id=value,
issue_assignee__deleted_at__isnull=True,
)
def filter_assignee_id_in(self, queryset, name, value):
"""Filter by assignee IDs (in), excluding soft deleted users"""
return queryset.filter(
issue_assignee__assignee_id__in=value,
issue_assignee__deleted_at__isnull=True,
)
def filter_cycle_id(self, queryset, name, value):
"""Filter by cycle ID, excluding soft deleted cycles"""
return queryset.filter(
issue_cycle__cycle_id=value,
issue_cycle__deleted_at__isnull=True,
)
def filter_cycle_id_in(self, queryset, name, value):
"""Filter by cycle IDs (in), excluding soft deleted cycles"""
return queryset.filter(
issue_cycle__cycle_id__in=value,
issue_cycle__deleted_at__isnull=True,
)
def filter_module_id(self, queryset, name, value):
"""Filter by module ID, excluding soft deleted modules"""
return queryset.filter(
issue_module__module_id=value,
issue_module__deleted_at__isnull=True,
)
def filter_module_id_in(self, queryset, name, value):
"""Filter by module IDs (in), excluding soft deleted modules"""
return queryset.filter(
issue_module__module_id__in=value,
issue_module__deleted_at__isnull=True,
)
def filter_mention_id(self, queryset, name, value):
"""Filter by mention ID, excluding soft deleted users"""
return queryset.filter(
issue_mention__mention_id=value,
issue_mention__deleted_at__isnull=True,
)
def filter_mention_id_in(self, queryset, name, value):
"""Filter by mention IDs (in), excluding soft deleted users"""
return queryset.filter(
issue_mention__mention_id__in=value,
issue_mention__deleted_at__isnull=True,
)
def filter_label_id(self, queryset, name, value):
"""Filter by label ID, excluding soft deleted labels"""
return queryset.filter(
label_issue__label_id=value,
label_issue__deleted_at__isnull=True,
)
def filter_label_id_in(self, queryset, name, value):
"""Filter by label IDs (in), excluding soft deleted labels"""
return queryset.filter(
label_issue__label_id__in=value,
label_issue__deleted_at__isnull=True,
)
def filter_subscriber_id(self, queryset, name, value):
"""Filter by subscriber ID, excluding soft deleted users"""
return queryset.filter(
issue_subscribers__subscriber_id=value,
issue_subscribers__deleted_at__isnull=True,
)
def filter_subscriber_id_in(self, queryset, name, value):
"""Filter by subscriber IDs (in), excluding soft deleted users"""
return queryset.filter(
issue_subscribers__subscriber_id__in=value,
issue_subscribers__deleted_at__isnull=True,
)
+10 -21
View File
@@ -148,6 +148,7 @@ def issue_group_values(
slug: str,
project_id: Optional[str] = None,
filters: Dict[str, Any] = {},
queryset: Optional[QuerySet] = None,
) -> List[Union[str, Any]]:
if field == "state_id":
queryset = State.objects.filter(
@@ -207,36 +208,24 @@ def issue_group_values(
return ["backlog", "unstarted", "started", "completed", "cancelled"]
if field == "target_date":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("target_date", flat=True)
.distinct()
)
queryset = queryset.values_list("target_date", flat=True).distinct()
if project_id:
return list(queryset.filter(project_id=project_id))
return list(queryset)
else:
return list(queryset)
if field == "start_date":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("start_date", flat=True)
.distinct()
)
queryset = queryset.values_list("start_date", flat=True).distinct()
if project_id:
return list(queryset.filter(project_id=project_id))
return list(queryset)
else:
return list(queryset)
if field == "created_by":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("created_by", flat=True)
.distinct()
)
queryset = queryset.values_list("created_by", flat=True).distinct()
if project_id:
return list(queryset.filter(project_id=project_id))
return list(queryset)
else:
return list(queryset)
return []
+2
View File
@@ -476,6 +476,8 @@ def filter_subscribed_issues(params, issue_filter, method, prefix=""):
issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = params.get(
"subscriber"
)
issue_filter[f"{prefix}issue_subscribers__deleted_at__isnull"] = True
return issue_filter
@@ -6,12 +6,14 @@ def get_inverse_relation(relation_type):
"blocking": "blocked_by",
"start_before": "start_after",
"finish_before": "finish_after",
"implemented_by": "implements",
"implements": "implemented_by",
}
return relation_mapping.get(relation_type, relation_type)
def get_actual_relation(relation_type):
# This function is used to get the actual relation type which is store in database
# This function is used to get the actual relation type which is stored in database
actual_relation = {
"start_after": "start_before",
"finish_after": "finish_before",
@@ -19,6 +21,8 @@ def get_actual_relation(relation_type):
"blocked_by": "blocked_by",
"start_before": "start_before",
"finish_before": "finish_before",
"implemented_by": "implemented_by",
"implements": "implemented_by",
}
return actual_relation.get(relation_type, relation_type)
+127 -5
View File
@@ -1,21 +1,143 @@
# Django imports
from django.utils.http import url_has_allowed_host_and_scheme
from django.conf import settings
# Python imports
from urllib.parse import urlparse
def _contains_suspicious_patterns(path: str) -> bool:
"""
Check for suspicious patterns that might indicate malicious intent.
Args:
path (str): The path to check
Returns:
bool: True if suspicious patterns found, False otherwise
"""
suspicious_patterns = [
r'javascript:', # JavaScript injection
r'data:', # Data URLs
r'vbscript:', # VBScript injection
r'file:', # File protocol
r'ftp:', # FTP protocol
r'%2e%2e', # URL encoded path traversal
r'%2f%2f', # URL encoded double slash
r'%5c%5c', # URL encoded backslashes
r'<script', # Script tags
r'<iframe', # Iframe tags
r'<object', # Object tags
r'<embed', # Embed tags
r'<form', # Form tags
r'onload=', # Event handlers
r'onerror=', # Event handlers
r'onclick=', # Event handlers
]
path_lower = path.lower()
for pattern in suspicious_patterns:
if pattern in path_lower:
return True
return False
def get_allowed_hosts() -> list[str]:
"""Get the allowed hosts from the settings."""
base_origin = settings.WEB_URL or settings.APP_BASE_URL
allowed_hosts = []
if base_origin:
host = urlparse(base_origin).netloc
allowed_hosts.append(host)
if settings.ADMIN_BASE_URL:
# Get only the host
host = urlparse(settings.ADMIN_BASE_URL).netloc
allowed_hosts.append(host)
if settings.SPACE_BASE_URL:
# Get only the host
host = urlparse(settings.SPACE_BASE_URL).netloc
allowed_hosts.append(host)
return allowed_hosts
def validate_next_path(next_path: str) -> str:
"""Validates that next_path is a valid path and extracts only the path component."""
"""Validates that next_path is a safe relative path for redirection."""
# Browsers interpret backslashes as forward slashes. Remove all backslashes.
if not next_path or not isinstance(next_path, str):
return ""
# Limit input length to prevent DoS attacks
if len(next_path) > 500:
return ""
next_path = next_path.replace("\\", "")
parsed_url = urlparse(next_path)
# Ensure next_path is not an absolute URL
# Block absolute URLs or anything with scheme/netloc
if parsed_url.scheme or parsed_url.netloc:
next_path = parsed_url.path # Extract only the path component
# Ensure it starts with a forward slash (indicating a valid relative path)
if not next_path.startswith("/"):
# Must start with a forward slash and not be empty
if not next_path or not next_path.startswith("/"):
return ""
# Ensure it does not contain dangerous path traversal sequences
# Prevent path traversal
if ".." in next_path:
return ""
# Additional security checks
if _contains_suspicious_patterns(next_path):
return ""
return next_path
def get_safe_redirect_url(base_url: str, next_path: str = "", params: dict = {}) -> str:
"""
Safely construct a redirect URL with validated next_path.
Args:
base_url (str): The base URL to redirect to
next_path (str): The next path to append
params (dict): The parameters to append
Returns:
str: The safe redirect URL
"""
from urllib.parse import urlencode, quote
# Validate the next path
validated_path = validate_next_path(next_path)
# Add the next path to the parameters
base_url = base_url.rstrip('/')
# Prepare the query parameters
query_parts = []
encoded_params = ""
# Add the next path to the parameters
if validated_path:
query_parts.append(f"next_path={validated_path}")
# Add additional parameters
if params:
encoded_params = urlencode(params)
query_parts.append(encoded_params)
# Construct the url query string
if query_parts:
query_string = "&".join(query_parts)
url = f"{base_url}/?{query_string}"
else:
url = base_url
# Check if the URL is allowed
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
return url
# Return the base URL if the URL is not allowed
return base_url + (f"?{encoded_params}" if encoded_params else "")
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.22
Django==4.2.24
# rest framework
djangorestframework==3.15.2
# postgres
@@ -13,6 +13,11 @@ export async function generateMetadata({ params }: Props) {
const { anchor } = params;
const DEFAULT_TITLE = "Plane";
const DEFAULT_DESCRIPTION = "Made with Plane, an AI-powered work management platform with publishing capabilities.";
// Validate anchor before using in request (only allow alphanumeric, -, _)
const ANCHOR_REGEX = /^[a-zA-Z0-9_-]+$/;
if (!ANCHOR_REGEX.test(anchor)) {
return { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION };
}
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/public/anchor/${anchor}/meta/`);
const data = await response.json();
+23 -2
View File
@@ -1,6 +1,9 @@
"use client";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useSearchParams, useRouter } from "next/navigation";
// plane imports
import { isValidNextPath } from "@plane/utils";
// components
import { UserLoggedIn } from "@/components/account/user-logged-in";
import { LogoSpinner } from "@/components/common/logo-spinner";
@@ -10,6 +13,15 @@ import { useUser } from "@/hooks/store/use-user";
const HomePage = observer(() => {
const { data: currentUser, isAuthenticated, isInitializing } = useUser();
const searchParams = useSearchParams();
const router = useRouter();
const nextPath = searchParams.get("next_path");
useEffect(() => {
if (currentUser && isAuthenticated && nextPath && isValidNextPath(nextPath)) {
router.replace(nextPath);
}
}, [currentUser, isAuthenticated, nextPath, router]);
if (isInitializing)
return (
@@ -18,7 +30,16 @@ const HomePage = observer(() => {
</div>
);
if (currentUser && isAuthenticated) return <UserLoggedIn />;
if (currentUser && isAuthenticated) {
if (nextPath && isValidNextPath(nextPath)) {
return (
<div className="flex h-screen min-h-[500px] w-full justify-center items-center">
<LogoSpinner />
</div>
);
}
return <UserLoggedIn />;
}
return <AuthView />;
});
@@ -2,7 +2,7 @@
import { observer } from "mobx-react";
import Image from "next/image";
import { PlaneLockup } from "@plane/ui";
import { PlaneLockup } from "@plane/propel/icons";
// components
import { PoweredBy } from "@/components/common/powered-by";
import { UserAvatar } from "@/components/issues/navbar/user-avatar";
@@ -3,7 +3,7 @@
import { FC } from "react";
import { WEBSITE_URL } from "@plane/constants";
// assets
import { PlaneLogo } from "@plane/ui";
import { PlaneLogo } from "@plane/propel/icons";
type TPoweredBy = {
disabled?: boolean;
@@ -1,7 +1,7 @@
"use client";
import { X } from "lucide-react";
import { PriorityIcon, type TIssuePriorities } from "@plane/ui";
import { PriorityIcon, type TIssuePriorities } from "@plane/propel/icons";
type Props = {
handleRemove: (val: string) => void;
@@ -4,7 +4,7 @@ import { observer } from "mobx-react";
import { X } from "lucide-react";
// plane imports
import { EIconSize } from "@plane/constants";
import { StateGroupIcon } from "@plane/ui";
import { StateGroupIcon } from "@plane/propel/icons";
// hooks
import { useStates } from "@/hooks/store/use-state";
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
// plane imports
import { ISSUE_PRIORITY_FILTERS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { PriorityIcon } from "@plane/ui";
import { PriorityIcon } from "@plane/propel/icons";
// local imports
import { FilterHeader } from "./helpers/filter-header";
import { FilterOption } from "./helpers/filter-option";
@@ -4,7 +4,8 @@ import React, { useState } from "react";
import { observer } from "mobx-react";
// ui
import { EIconSize } from "@plane/constants";
import { Loader, StateGroupIcon } from "@plane/ui";
import { StateGroupIcon } from "@plane/propel/icons";
import { Loader } from "@plane/ui";
// hooks
import { useStates } from "@/hooks/store/use-state";
// local imports
@@ -2,8 +2,8 @@
import { observer } from "mobx-react";
// plane ui
import { ContrastIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { ContrastIcon } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
//hooks
@@ -2,8 +2,8 @@
import { observer } from "mobx-react";
// plane ui
import { DiceIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { DiceIcon } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// hooks
@@ -3,9 +3,9 @@
import { SignalHigh } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// types
import { PriorityIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { TIssuePriorities } from "@plane/types";
import { PriorityIcon } from "@plane/ui";
// constants
import { cn, getIssuePriorityFilters } from "@plane/utils";
@@ -2,8 +2,8 @@
import { observer } from "mobx-react";
// plane ui
import { StateGroupIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { StateGroupIcon } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
//hooks
@@ -4,6 +4,7 @@ import isNil from "lodash/isNil";
import { ContrastIcon } from "lucide-react";
// types
import { EIconSize, ISSUE_PRIORITIES } from "@plane/constants";
import { CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/propel/icons";
import {
GroupByColumnTypes,
IGroupByColumn,
@@ -12,7 +13,7 @@ import {
TGroupedIssues,
} from "@plane/types";
// ui
import { Avatar, CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/ui";
import { Avatar } from "@plane/ui";
// components
// constants
// stores
@@ -5,7 +5,8 @@ import { observer } from "mobx-react";
import { Link2, MoveRight } from "lucide-react";
import { Listbox, Transition } from "@headlessui/react";
// ui
import { CenterPanelIcon, FullScreenPanelIcon, setToast, SidePanelIcon, TOAST_TYPE } from "@plane/ui";
import { CenterPanelIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons";
import { setToast, TOAST_TYPE } from "@plane/ui";
// helpers
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
@@ -25,18 +25,12 @@ export const PeekOverviewIssueDetails: React.FC<Props> = observer((props) => {
{project_details?.identifier}-{issueDetails?.sequence_id}
</h6>
<h4 className="break-words text-2xl font-medium">{issueDetails.name}</h4>
{description !== "" && description !== "<p></p>" && (
{description && description !== "" && description !== "<p></p>" && (
<RichTextEditor
editable={false}
anchor={anchor}
id={issueDetails.id}
initialValue={
!description ||
description === "" ||
(typeof description === "object" && Object.keys(description).length === 0)
? "<p></p>"
: description
}
initialValue={description}
workspaceId={workspaceID?.toString() ?? ""}
/>
)}
@@ -5,7 +5,8 @@ import { useParams } from "next/navigation";
import { CalendarCheck2, Signal } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { DoubleCircleIcon, StateGroupIcon, TOAST_TYPE, setToast } from "@plane/ui";
import { DoubleCircleIcon, StateGroupIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/ui";
import { cn, getIssuePriorityFilters } from "@plane/utils";
// components
import { Icon } from "@/components/ui";
+1 -1
View File
@@ -2,7 +2,7 @@
import React from "react";
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,7 +2,8 @@
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// ui
import { Breadcrumbs, ContrastIcon, Header } from "@plane/ui";
import { ContrastIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
// plane web components
@@ -8,8 +8,9 @@ import { useParams } from "next/navigation";
import { ChevronDown, PanelRight } from "lucide-react";
import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { UserActivityIcon } from "@plane/propel/icons";
import { IUserProfileProjectSegregation } from "@plane/types";
import { Breadcrumbs, Header, CustomMenu, UserActivityIcon } from "@plane/ui";
import { Breadcrumbs, Header, CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
@@ -14,19 +14,16 @@ import {
EIssuesStoreType,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
TIssueLayouts,
EIssueLayoutTypes,
} from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
// components
import { isIssueFilterActive } from "@plane/utils";
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
export const ProfileIssuesMobileHeader = observer(() => {
// plane i18n
@@ -37,14 +34,7 @@ export const ProfileIssuesMobileHeader = observer(() => {
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.PROFILE);
const { workspaceLabels } = useLabel();
// derived values
const states = undefined;
// const members = undefined;
// const activeLayout = issueFilters?.displayFilters?.layout;
// const states = undefined;
const members = undefined;
const activeLayout = issueFilters?.displayFilters?.layout;
const handleLayoutChange = useCallback(
@@ -61,31 +51,6 @@ export const ProfileIssuesMobileHeader = observer(() => {
[workspaceSlug, updateFilters, userId]
);
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !userId) return;
const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
});
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(
workspaceSlug.toString(),
undefined,
EIssueFilterType.FILTERS,
{ [key]: newValues },
userId.toString()
);
},
[workspaceSlug, issueFilters, updateFilters, userId]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !userId) return;
@@ -145,32 +110,6 @@ export const ProfileIssuesMobileHeader = observer(() => {
);
})}
</CustomMenu>
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
<FiltersDropdown
title={t("common.filters")}
placement="bottom-end"
menuButton={
<div className="flex flex-center text-sm text-custom-text-200">
{t("common.filters")}
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" strokeWidth={2} />
</div>
}
isFiltersApplied={isIssueFilterActive(issueFilters)}
>
<FilterSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues[activeLayout] : undefined
}
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
states={states}
labels={workspaceLabels}
memberIds={members}
/>
</FiltersDropdown>
</div>
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
<FiltersDropdown
title={t("common.display")}
@@ -184,7 +123,7 @@ export const ProfileIssuesMobileHeader = observer(() => {
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues[activeLayout] : undefined
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues.layoutOptions[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
@@ -3,10 +3,11 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ArchiveIcon, ContrastIcon, DiceIcon, LayersIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { EIssuesStoreType } from "@plane/types";
// ui
import { ArchiveIcon, Breadcrumbs, Header, ContrastIcon, DiceIcon, LayersIcon } from "@plane/ui";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
// hooks
@@ -4,7 +4,8 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// ui
import { ArchiveIcon, Breadcrumbs, LayersIcon, Header } from "@plane/ui";
import { ArchiveIcon, LayersIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions";
@@ -4,7 +4,7 @@ import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// icons
import { ChartNoAxesColumn, ListFilter, PanelRight, SlidersHorizontal } from "lucide-react";
import { ChartNoAxesColumn, PanelRight, SlidersHorizontal } from "lucide-react";
// plane imports
import {
EIssueFilterType,
@@ -16,17 +16,17 @@ import {
} from "@plane/constants";
import { usePlatformOS } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { ContrastIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import {
EIssuesStoreType,
ICustomSearchSelectOption,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
import { Breadcrumbs, Button, ContrastIcon, BreadcrumbNavigationSearchDropdown, Header } from "@plane/ui";
import { cn, isIssueFilterActive } from "@plane/utils";
import { Breadcrumbs, Button, BreadcrumbNavigationSearchDropdown, Header } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import { SwitcherLabel } from "@/components/common/switcher-label";
@@ -34,7 +34,6 @@ import { CycleQuickActions } from "@/components/cycles/quick-actions";
import {
DisplayFiltersSelection,
FiltersDropdown,
FilterSelection,
LayoutSelection,
MobileLayoutSelection,
} from "@/components/issues/issue-layouts/filters";
@@ -42,10 +41,7 @@ import {
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import useLocalStorage from "@/hooks/use-local-storage";
@@ -74,11 +70,6 @@ export const CycleIssuesHeader: React.FC = observer(() => {
const { currentProjectCycleIds, getCycleById } = useCycle();
const { toggleCreateIssueModal } = useCommandPalette();
const { currentProjectDetails, loader } = useProject();
const { projectStates } = useProjectState();
const { projectLabels } = useLabel();
const {
project: { projectMemberIds },
} = useMember();
const { isMobile } = usePlatformOS();
const { allowPermissions } = useUserPermissions();
@@ -99,27 +90,6 @@ export const CycleIssuesHeader: React.FC = observer(() => {
[workspaceSlug, projectId, cycleId, updateFilters]
);
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return;
const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, cycleId);
},
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
@@ -238,27 +208,6 @@ export const CycleIssuesHeader: React.FC = observer(() => {
activeLayout={activeLayout}
/>
</div>
<FiltersDropdown
title={t("common.filters")}
placement="bottom-end"
isFiltersApplied={isIssueFilterActive(issueFilters)}
miniIcon={<ListFilter className="size-3.5" />}
>
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
<FiltersDropdown
title={t("common.display")}
placement="bottom-end"
@@ -266,7 +215,7 @@ export const CycleIssuesHeader: React.FC = observer(() => {
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
@@ -7,48 +7,39 @@ import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
// plane imports
import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import {
EIssuesStoreType,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { isIssueFilterActive } from "@plane/utils";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
const SUPPORTED_LAYOUTS = [
{ key: "list", titleTranslationKey: "issue.layouts.list", icon: List },
{ key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: Kanban },
{ key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: Calendar },
];
export const CycleIssuesMobileHeader = () => {
// i18n
const { t } = useTranslation();
const [analyticsModal, setAnalyticsModal] = useState(false);
const { getCycleById } = useCycle();
const layouts = [
{ key: "list", titleTranslationKey: "issue.layouts.list", icon: List },
{ key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: Kanban },
{ key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: Calendar },
];
// router
const { workspaceSlug, projectId, cycleId } = useParams();
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
// states
const [analyticsModal, setAnalyticsModal] = useState(false);
// plane hooks
const { t } = useTranslation();
// store hooks
const { currentProjectDetails } = useProject();
const { getCycleById } = useCycle();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.CYCLE);
// derived values
const activeLayout = issueFilters?.displayFilters?.layout;
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
const handleLayoutChange = useCallback(
(layout: EIssueLayoutTypes) => {
@@ -64,37 +55,6 @@ export const CycleIssuesMobileHeader = () => {
[workspaceSlug, projectId, cycleId, updateFilters]
);
const { projectStates } = useProjectState();
const { projectLabels } = useLabel();
const {
project: { projectMemberIds },
} = useMember();
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId || !cycleId) return;
const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
});
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{ [key]: newValues },
cycleId.toString()
);
},
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId || !cycleId) return;
@@ -142,7 +102,7 @@ export const CycleIssuesMobileHeader = () => {
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
closeOnSelect
>
{layouts.map((layout, index) => (
{SUPPORTED_LAYOUTS.map((layout, index) => (
<CustomMenu.MenuItem
key={ISSUE_LAYOUTS[index].key}
onClick={() => {
@@ -155,34 +115,6 @@ export const CycleIssuesMobileHeader = () => {
</CustomMenu.MenuItem>
))}
</CustomMenu>
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
<FiltersDropdown
title={t("common.filters")}
placement="bottom-end"
menuButton={
<span className="flex items-center text-custom-text-200 text-sm">
{t("common.filters")}
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
</span>
}
isFiltersApplied={isIssueFilterActive(issueFilters)}
>
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
</div>
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
<FiltersDropdown
title={t("common.display")}
@@ -196,7 +128,7 @@ export const CycleIssuesMobileHeader = () => {
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
@@ -7,28 +7,17 @@ import { ChevronDown } from "lucide-react";
// plane imports
import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import {
EIssuesStoreType,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
import { isIssueFilterActive } from "@plane/utils";
import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import {
DisplayFiltersSelection,
FilterSelection,
FiltersDropdown,
MobileLayoutSelection,
} from "@/components/issues/issue-layouts/filters";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
export const ProjectIssuesMobileHeader = observer(() => {
// i18n
@@ -39,16 +28,11 @@ export const ProjectIssuesMobileHeader = observer(() => {
projectId: string;
};
const { currentProjectDetails } = useProject();
const { projectStates } = useProjectState();
const { projectLabels } = useLabel();
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.PROJECT);
const {
project: { projectMemberIds },
} = useMember();
const activeLayout = issueFilters?.displayFilters?.layout;
const handleLayoutChange = useCallback(
@@ -59,27 +43,6 @@ export const ProjectIssuesMobileHeader = observer(() => {
[workspaceSlug, projectId, updateFilters]
);
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return;
const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues });
},
[workspaceSlug, projectId, issueFilters, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
@@ -108,34 +71,6 @@ export const ProjectIssuesMobileHeader = observer(() => {
layouts={[EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN, EIssueLayoutTypes.CALENDAR]}
onChange={handleLayoutChange}
/>
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
<FiltersDropdown
title={t("common.filters")}
placement="bottom-end"
menuButton={
<span className="flex items-center text-sm text-custom-text-200">
{t("common.filters")}
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" />
</span>
}
isFiltersApplied={isIssueFilterActive(issueFilters)}
>
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
</div>
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
<FiltersDropdown
title={t("common.display")}
@@ -149,7 +84,7 @@ export const ProjectIssuesMobileHeader = observer(() => {
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
@@ -4,7 +4,7 @@ import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// icons
import { ChartNoAxesColumn, ListFilter, PanelRight, SlidersHorizontal } from "lucide-react";
import { ChartNoAxesColumn, PanelRight, SlidersHorizontal } from "lucide-react";
// plane imports
import {
EIssueFilterType,
@@ -14,24 +14,23 @@ import {
EProjectFeatureKey,
WORK_ITEM_TRACKER_ELEMENTS,
} from "@plane/constants";
import { DiceIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import {
EIssuesStoreType,
ICustomSearchSelectOption,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
import { Breadcrumbs, Button, DiceIcon, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
import { cn, isIssueFilterActive } from "@plane/utils";
import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import { SwitcherLabel } from "@/components/common/switcher-label";
import {
DisplayFiltersSelection,
FiltersDropdown,
FilterSelection,
LayoutSelection,
MobileLayoutSelection,
} from "@/components/issues/issue-layouts/filters";
@@ -40,11 +39,8 @@ import { ModuleQuickActions } from "@/components/modules";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useModule } from "@/hooks/store/use-module";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { useIssuesActions } from "@/hooks/use-issues-actions";
@@ -73,21 +69,22 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
const { toggleCreateIssueModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { currentProjectDetails, loader } = useProject();
const { projectLabels } = useLabel();
const { projectStates } = useProjectState();
const {
project: { projectMemberIds },
} = useMember();
// local storage
const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false");
// derived values
const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false;
const activeLayout = issueFilters?.displayFilters?.layout;
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
const canUserCreateIssue = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
const workItemsCount = getGroupIssueCount(undefined, undefined, false);
const toggleSidebar = () => {
setValue(`${!isSidebarCollapsed}`);
};
const activeLayout = issueFilters?.displayFilters?.layout;
const handleLayoutChange = useCallback(
(layout: EIssueLayoutTypes) => {
if (!projectId) return;
@@ -96,27 +93,6 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
[projectId, updateFilters]
);
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!projectId) return;
const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(projectId.toString(), EIssueFilterType.FILTERS, { [key]: newValues });
},
[projectId, issueFilters, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!projectId) return;
@@ -133,15 +109,6 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
[projectId, updateFilters]
);
// derived values
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
const canUserCreateIssue = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
const workItemsCount = getGroupIssueCount(undefined, undefined, false);
const switcherOptions = projectModuleIds
?.map((id) => {
const _module = id === moduleId ? moduleDetails : getModuleById(id);
@@ -229,27 +196,6 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
activeLayout={activeLayout}
/>
</div>
<FiltersDropdown
title="Filters"
placement="bottom-end"
isFiltersApplied={isIssueFilterActive(issueFilters)}
miniIcon={<ListFilter className="size-3.5" />}
>
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
<FiltersDropdown
title="Display"
placement="bottom-end"
@@ -257,7 +203,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
@@ -8,53 +8,43 @@ import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
// plane imports
import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import {
EIssuesStoreType,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { isIssueFilterActive } from "@plane/utils";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useModule } from "@/hooks/store/use-module";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
const SUPPORTED_LAYOUTS = [
{ key: "list", i18n_title: "issue.layouts.list", icon: List },
{ key: "kanban", i18n_title: "issue.layouts.kanban", icon: Kanban },
{ key: "calendar", i18n_title: "issue.layouts.calendar", icon: Calendar },
];
export const ModuleIssuesMobileHeader = observer(() => {
const [analyticsModal, setAnalyticsModal] = useState(false);
const { currentProjectDetails } = useProject();
const { getModuleById } = useModule();
const { t } = useTranslation();
const layouts = [
{ key: "list", i18n_title: "issue.layouts.list", icon: List },
{ key: "kanban", i18n_title: "issue.layouts.kanban", icon: Kanban },
{ key: "calendar", i18n_title: "issue.layouts.calendar", icon: Calendar },
];
// router
const { workspaceSlug, projectId, moduleId } = useParams() as {
workspaceSlug: string;
projectId: string;
moduleId: string;
};
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
// states
const [analyticsModal, setAnalyticsModal] = useState(false);
// plane hooks
const { t } = useTranslation();
// store hooks
const { currentProjectDetails } = useProject();
const { getModuleById } = useModule();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.MODULE);
// derived values
const activeLayout = issueFilters?.displayFilters?.layout;
const { projectStates } = useProjectState();
const { projectLabels } = useLabel();
const {
project: { projectMemberIds },
} = useMember();
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
const handleLayoutChange = useCallback(
(layout: EIssueLayoutTypes) => {
@@ -64,27 +54,6 @@ export const ModuleIssuesMobileHeader = observer(() => {
[workspaceSlug, projectId, moduleId, updateFilters]
);
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return;
const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, moduleId);
},
[workspaceSlug, projectId, moduleId, issueFilters, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
@@ -118,7 +87,7 @@ export const ModuleIssuesMobileHeader = observer(() => {
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
closeOnSelect
>
{layouts.map((layout, index) => (
{SUPPORTED_LAYOUTS.map((layout, index) => (
<CustomMenu.MenuItem
key={layout.key}
onClick={() => {
@@ -131,34 +100,6 @@ export const ModuleIssuesMobileHeader = observer(() => {
</CustomMenu.MenuItem>
))}
</CustomMenu>
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
<FiltersDropdown
title="Filters"
placement="bottom-end"
menuButton={
<span className="flex items-center text-sm text-custom-text-200">
Filters
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" />
</span>
}
isFiltersApplied={isIssueFilterActive(issueFilters)}
>
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
</div>
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
<FiltersDropdown
title="Display"
@@ -172,7 +113,7 @@ export const ModuleIssuesMobileHeader = observer(() => {
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
@@ -21,29 +21,19 @@ import {
ICustomSearchSelectOption,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
// ui
import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
// components
import { isIssueFilterActive } from "@plane/utils";
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
import {
DisplayFiltersSelection,
FiltersDropdown,
FilterSelection,
LayoutSelection,
} from "@/components/issues/issue-layouts/filters";
import { DisplayFiltersSelection, FiltersDropdown, LayoutSelection } from "@/components/issues/issue-layouts/filters";
// constants
import { ViewQuickActions } from "@/components/views/quick-actions";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useProjectView } from "@/hooks/store/use-project-view";
import { useUserPermissions } from "@/hooks/store/user";
// plane web
@@ -65,11 +55,6 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
const { currentProjectDetails, loader } = useProject();
const { projectViewIds, getViewById } = useProjectView();
const { projectStates } = useProjectState();
const { projectLabels } = useLabel();
const {
project: { projectMemberIds },
} = useMember();
const activeLayout = issueFilters?.displayFilters?.layout;
@@ -87,33 +72,6 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
[workspaceSlug, projectId, viewId, updateFilters]
);
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId || !viewId) return;
const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{ [key]: newValues },
viewId.toString()
);
},
[workspaceSlug, projectId, viewId, issueFilters, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId || !viewId) return;
@@ -217,33 +175,10 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
<FiltersDropdown
title="Filters"
placement="bottom-end"
disabled={!canUserCreateIssue}
isFiltersApplied={isIssueFilterActive(issueFilters)}
>
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
}
projectId={projectId.toString()}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
<FiltersDropdown title="Display" placement="bottom-end">
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
@@ -3,7 +3,8 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { Breadcrumbs, Button, Header, RecentStickyIcon } from "@plane/ui";
import { RecentStickyIcon } from "@plane/propel/icons";
import { Breadcrumbs, Button, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { StickySearch } from "@/components/stickies/modal/search";
@@ -7,7 +7,6 @@ import { useParams } from "next/navigation";
import { DEFAULT_GLOBAL_VIEWS_LIST } from "@plane/constants";
// components
import { PageHead } from "@/components/core/page-title";
import { GlobalViewsAppliedFiltersRoot } from "@/components/issues/issue-layouts/filters";
import { AllIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/all-issue-layout-root";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
@@ -29,14 +28,7 @@ const GlobalViewIssuesPage = observer(() => {
return (
<>
<PageHead title={pageTitle} />
<div className="h-full overflow-hidden bg-custom-background-100">
<div className="flex h-full w-full flex-col border-b border-custom-border-300">
{globalViewId && (
<GlobalViewsAppliedFiltersRoot globalViewId={globalViewId.toString()} isLoading={isLoading} />
)}
<AllIssueLayoutRoot isDefaultView={!!defaultView} isLoading={isLoading} toggleLoading={toggleLoading} />
</div>
</div>
<AllIssueLayoutRoot isDefaultView={!!defaultView} isLoading={isLoading} toggleLoading={toggleLoading} />
</>
);
});
@@ -16,24 +16,20 @@ import {
EIssuesStoreType,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
ICustomSearchSelectOption,
EIssueLayoutTypes,
} from "@plane/types";
import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
import { isIssueFilterActive } from "@plane/utils";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SwitcherLabel } from "@/components/common/switcher-label";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection } from "@/components/issues/issue-layouts/filters";
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
import { DefaultWorkspaceViewQuickActions } from "@/components/workspace/views/default-view-quick-action";
import { CreateUpdateWorkspaceViewModal } from "@/components/workspace/views/modal";
import { WorkspaceViewQuickActions } from "@/components/workspace/views/quick-action";
// hooks
import { useGlobalView } from "@/hooks/store/use-global-view";
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useAppRouter } from "@/hooks/use-app-router";
import { GlobalViewLayoutSelection } from "@/plane-web/components/views/helper";
@@ -48,10 +44,6 @@ export const GlobalIssuesHeader = observer(() => {
issuesFilter: { filters, updateFilters },
} = useIssues(EIssuesStoreType.GLOBAL);
const { getViewDetailsById, currentWorkspaceViews } = useGlobalView();
const { workspaceLabels } = useLabel();
const {
workspace: { workspaceMemberIds },
} = useMember();
const { t } = useTranslation();
const issueFilters = globalViewId ? filters[globalViewId.toString()] : undefined;
@@ -59,33 +51,6 @@ export const GlobalIssuesHeader = observer(() => {
const activeLayout = issueFilters?.displayFilters?.layout;
const viewDetails = getViewDetailsById(globalViewId.toString());
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !globalViewId) return;
const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(
workspaceSlug.toString(),
undefined,
EIssueFilterType.FILTERS,
{ [key]: newValues },
globalViewId.toString()
);
},
[workspaceSlug, issueFilters, updateFilters, globalViewId]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !globalViewId) return;
@@ -155,7 +120,7 @@ export const GlobalIssuesHeader = observer(() => {
) as ICustomSearchSelectOption[];
const currentLayoutFilters = useMemo(() => {
const layout = activeLayout ?? EIssueLayoutTypes.SPREADSHEET;
return ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues[layout];
return ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.layoutOptions[layout];
}, [activeLayout]);
return (
@@ -191,7 +156,7 @@ export const GlobalIssuesHeader = observer(() => {
</Breadcrumbs>
</Header.LeftItem>
<Header.RightItem>
<Header.RightItem className="items-center">
{!isLocked ? (
<>
<GlobalViewLayoutSelection
@@ -199,21 +164,6 @@ export const GlobalIssuesHeader = observer(() => {
selectedLayout={activeLayout ?? EIssueLayoutTypes.SPREADSHEET}
workspaceSlug={workspaceSlug.toString()}
/>
<FiltersDropdown
title={t("common.filters")}
placement="bottom-end"
isFiltersApplied={isIssueFilterActive(issueFilters)}
>
<FilterSelection
layoutDisplayFiltersOptions={currentLayoutFilters}
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
labels={workspaceLabels ?? undefined}
memberIds={workspaceMemberIds ?? undefined}
/>
</FiltersDropdown>
<FiltersDropdown title={t("common.display")} placement="bottom-end">
<DisplayFiltersSelection
layoutDisplayFiltersOptions={currentLayoutFilters}
@@ -20,6 +20,7 @@ import { cn } from "@plane/utils";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { CountChip } from "@/components/common/count-chip";
import { PageHead } from "@/components/core/page-title";
import { MemberListFiltersDropdown } from "@/components/project/dropdowns/filters/member-list";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { WorkspaceMembersList } from "@/components/workspace/settings/members-list";
// helpers
@@ -41,7 +42,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
// store hooks
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const {
workspace: { workspaceMemberIds, inviteMembersToWorkspace },
workspace: { workspaceMemberIds, inviteMembersToWorkspace, filtersStore },
} = useMember();
const { currentWorkspace } = useWorkspace();
const { t } = useTranslation();
@@ -88,8 +89,20 @@ const WorkspaceMembersSettingsPage = observer(() => {
});
};
// Handler for role filter updates
const handleRoleFilterUpdate = (role: string) => {
const currentFilters = filtersStore.filters;
const currentRoles = currentFilters?.roles || [];
const updatedRoles = currentRoles.includes(role) ? currentRoles.filter((r) => r !== role) : [...currentRoles, role];
filtersStore.updateFilters({
roles: updatedRoles.length > 0 ? updatedRoles : undefined,
});
};
// derived values
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Members` : undefined;
const appliedRoleFilters = filtersStore.filters?.roles || [];
// if user is not authorized to view this page
if (workspaceUserInfo && !canPerformWorkspaceMemberActions) {
@@ -116,27 +129,34 @@ const WorkspaceMembersSettingsPage = observer(() => {
<CountChip count={workspaceMemberIds.length} className="h-5 m-auto" />
)}
</h4>
<div className="ml-auto flex items-center gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5">
<Search className="h-3.5 w-3.5 text-custom-text-400" />
<input
className="w-full max-w-[234px] border-none bg-transparent text-sm outline-none placeholder:text-custom-text-400"
placeholder={`${t("search")}...`}
value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5">
<Search className="h-3.5 w-3.5 text-custom-text-400" />
<input
className="w-full max-w-[234px] border-none bg-transparent text-sm outline-none placeholder:text-custom-text-400"
placeholder={`${t("search")}...`}
value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<MemberListFiltersDropdown
appliedFilters={appliedRoleFilters}
handleUpdate={handleRoleFilterUpdate}
memberType="workspace"
/>
{canPerformWorkspaceAdminActions && (
<Button
variant="primary"
size="sm"
onClick={() => setInviteModal(true)}
data-ph-element={MEMBER_TRACKER_ELEMENTS.HEADER_ADD_BUTTON}
>
{t("workspace_settings.settings.members.add_member")}
</Button>
)}
<BillingActionsButton canPerformWorkspaceAdminActions={canPerformWorkspaceAdminActions} />
</div>
{canPerformWorkspaceAdminActions && (
<Button
variant="primary"
size="sm"
onClick={() => setInviteModal(true)}
data-ph-element={MEMBER_TRACKER_ELEMENTS.HEADER_ADD_BUTTON}
>
{t("workspace_settings.settings.members.add_member")}
</Button>
)}
<BillingActionsButton canPerformWorkspaceAdminActions={canPerformWorkspaceAdminActions} />
</div>
<WorkspaceMembersList searchQuery={searchQuery} isAdmin={canPerformWorkspaceAdminActions} />
</section>
+2 -1
View File
@@ -6,8 +6,9 @@ import Image from "next/image";
import Link from "next/link";
// plane imports
import { useTranslation } from "@plane/i18n";
import { PlaneLogo } from "@plane/propel/icons";
import { IWorkspace } from "@plane/types";
import { Button, getButtonStyling, PlaneLogo } from "@plane/ui";
import { Button, getButtonStyling } from "@plane/ui";
// components
import { CreateWorkspaceForm } from "@/components/workspace/create-workspace-form";
// hooks
+2 -1
View File
@@ -10,9 +10,10 @@ import { CheckCircle2 } from "lucide-react";
import { ROLE, MEMBER_TRACKER_EVENTS, MEMBER_TRACKER_ELEMENTS, GROUP_WORKSPACE_TRACKER_EVENT } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// types
import { PlaneLogo } from "@plane/propel/icons";
import type { IWorkspaceMemberInvitation } from "@plane/types";
// ui
import { Button, TOAST_TYPE, setToast, PlaneLogo } from "@plane/ui";
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
import { truncateText } from "@plane/utils";
// components
import { EmptyState } from "@/components/common/empty-state";

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